Bug 1533872 - Update the matrix-js-sdk to v2.4.1. rs=florian
authorPatrick Cloke <clokep@gmail.com>
Tue, 22 Oct 2019 12:07:09 -0400
changeset 27983 d296be7570aebc76856d3539c51a698ce199b1ef
parent 27982 64c4bc07ed19f7f358fc2e8a8805977d6c0d73e8
child 27984 d31d845ea41e096fba0c805591e1a122c5c319ed
push id16594
push userclokep@gmail.com
push dateTue, 22 Oct 2019 16:08:26 +0000
treeherdercomm-central@40da1ce45af3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersflorian
bugs1533872
Bug 1533872 - Update the matrix-js-sdk to v2.4.1. rs=florian
chat/protocols/matrix/lib/matrix-sdk/LICENSE
chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js
chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js
chat/protocols/matrix/lib/matrix-sdk/base-apis.js
chat/protocols/matrix/lib/matrix-sdk/client.js
chat/protocols/matrix/lib/matrix-sdk/content-helpers.js
chat/protocols/matrix/lib/matrix-sdk/content-repo.js
chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js
chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js
chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js
chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js
chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js
chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js
chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js
chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js
chat/protocols/matrix/lib/matrix-sdk/crypto/backup_password.js
chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js
chat/protocols/matrix/lib/matrix-sdk/crypto/index.js
chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js
chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js
chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js
chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js
chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js
chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js
chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js
chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js
chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js
chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js
chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js
chat/protocols/matrix/lib/matrix-sdk/errors.js
chat/protocols/matrix/lib/matrix-sdk/filter-component.js
chat/protocols/matrix/lib/matrix-sdk/filter.js
chat/protocols/matrix/lib/matrix-sdk/http-api.js
chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js
chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js
chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js
chat/protocols/matrix/lib/matrix-sdk/logger.js
chat/protocols/matrix/lib/matrix-sdk/matrix.js
chat/protocols/matrix/lib/matrix-sdk/models/event-context.js
chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js
chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js
chat/protocols/matrix/lib/matrix-sdk/models/event.js
chat/protocols/matrix/lib/matrix-sdk/models/group.js
chat/protocols/matrix/lib/matrix-sdk/models/relations.js
chat/protocols/matrix/lib/matrix-sdk/models/room-member.js
chat/protocols/matrix/lib/matrix-sdk/models/room-state.js
chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js
chat/protocols/matrix/lib/matrix-sdk/models/room.js
chat/protocols/matrix/lib/matrix-sdk/models/search-result.js
chat/protocols/matrix/lib/matrix-sdk/models/user.js
chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js
chat/protocols/matrix/lib/matrix-sdk/randomstring.js
chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js
chat/protocols/matrix/lib/matrix-sdk/scheduler.js
chat/protocols/matrix/lib/matrix-sdk/service-types.js
chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js
chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js
chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js
chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js
chat/protocols/matrix/lib/matrix-sdk/store/memory.js
chat/protocols/matrix/lib/matrix-sdk/store/session/webstorage.js
chat/protocols/matrix/lib/matrix-sdk/store/stub.js
chat/protocols/matrix/lib/matrix-sdk/store/webstorage.js
chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js
chat/protocols/matrix/lib/matrix-sdk/sync.js
chat/protocols/matrix/lib/matrix-sdk/timeline-window.js
chat/protocols/matrix/lib/matrix-sdk/utils.js
chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js
chat/protocols/matrix/lib/moz.build
chat/protocols/matrix/matrix-sdk.jsm
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/LICENSE
@@ -0,0 +1,177 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/ReEmitter.js
@@ -0,0 +1,58 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2017 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * @module
+ */
+
+class Reemitter {
+    constructor(target) {
+        this.target = target;
+
+        // We keep one bound event handler for each event name so we know
+        // what event is arriving
+        this.boundHandlers = {};
+    }
+
+    _handleEvent(eventName, ...args) {
+        this.target.emit(eventName, ...args);
+    }
+
+    reEmit(source, eventNames) {
+        // We include the source as the last argument for event handlers which may need it,
+        // such as read receipt listeners on the client class which won't have the context
+        // of the room.
+        const forSource = (handler, ...args) => {
+            handler(...args, source);
+        };
+        for (const eventName of eventNames) {
+            if (this.boundHandlers[eventName] === undefined) {
+                this.boundHandlers[eventName] = this._handleEvent.bind(this, eventName);
+            }
+
+            const boundHandler = forSource.bind(this, this.boundHandlers[eventName]);
+            source.on(eventName, boundHandler);
+        }
+    }
+}
+exports.default = Reemitter;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/autodiscovery.js
@@ -0,0 +1,540 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.AutoDiscovery = undefined;
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('./logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _url = require('url');
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+// Dev note: Auto discovery is part of the spec.
+// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
+
+/**
+ * Description for what an automatically discovered client configuration
+ * would look like. Although this is a class, it is recommended that it
+ * be treated as an interface definition rather than as a class.
+ *
+ * Additional properties than those defined here may be present, and
+ * should follow the Java package naming convention.
+ */
+class DiscoveredClientConfig {
+    // eslint-disable-line no-unused-vars
+    // Dev note: this is basically a copy/paste of the .well-known response
+    // object as defined in the spec. It does have additional information,
+    // however. Overall, this exists to serve as a place for documentation
+    // and not functionality.
+    // See https://matrix.org/docs/spec/client_server/r0.4.0.html#get-well-known-matrix-client
+
+    constructor() {
+        /**
+         * The homeserver configuration the client should use. This will
+         * always be present on the object.
+         * @type {{state: string, base_url: string}} The configuration.
+         */
+        this["m.homeserver"] = {
+            /**
+             * The lookup result state. If this is anything other than
+             * AutoDiscovery.SUCCESS then base_url may be falsey. Additionally,
+             * if this is not AutoDiscovery.SUCCESS then the client should
+             * assume the other properties in the client config (such as
+             * the identity server configuration) are not valid.
+             */
+            state: AutoDiscovery.PROMPT,
+
+            /**
+             * If the state is AutoDiscovery.FAIL_ERROR or .FAIL_PROMPT
+             * then this will contain a human-readable (English) message
+             * for what went wrong. If the state is none of those previously
+             * mentioned, this will be falsey.
+             */
+            error: "Something went wrong",
+
+            /**
+             * The base URL clients should use to talk to the homeserver,
+             * particularly for the login process. May be falsey if the
+             * state is not AutoDiscovery.SUCCESS.
+             */
+            base_url: "https://matrix.org"
+        };
+
+        /**
+         * The identity server configuration the client should use. This
+         * will always be present on teh object.
+         * @type {{state: string, base_url: string}} The configuration.
+         */
+        this["m.identity_server"] = {
+            /**
+             * The lookup result state. If this is anything other than
+             * AutoDiscovery.SUCCESS then base_url may be falsey.
+             */
+            state: AutoDiscovery.PROMPT,
+
+            /**
+             * The base URL clients should use for interacting with the
+             * identity server. May be falsey if the state is not
+             * AutoDiscovery.SUCCESS.
+             */
+            base_url: "https://vector.im"
+        };
+    }
+}
+
+/**
+ * Utilities for automatically discovery resources, such as homeservers
+ * for users to log in to.
+ */
+/*
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/** @module auto-discovery */
+
+class AutoDiscovery {
+    // Dev note: the constants defined here are related to but not
+    // exactly the same as those in the spec. This is to hopefully
+    // translate the meaning of the states in the spec, but also
+    // support our own if needed.
+
+    static get ERROR_INVALID() {
+        return "Invalid homeserver discovery response";
+    }
+
+    static get ERROR_GENERIC_FAILURE() {
+        return "Failed to get autodiscovery configuration from server";
+    }
+
+    static get ERROR_INVALID_HS_BASE_URL() {
+        return "Invalid base_url for m.homeserver";
+    }
+
+    static get ERROR_INVALID_HOMESERVER() {
+        return "Homeserver URL does not appear to be a valid Matrix homeserver";
+    }
+
+    static get ERROR_INVALID_IS_BASE_URL() {
+        return "Invalid base_url for m.identity_server";
+    }
+
+    static get ERROR_INVALID_IDENTITY_SERVER() {
+        return "Identity server URL does not appear to be a valid identity server";
+    }
+
+    static get ERROR_INVALID_IS() {
+        return "Invalid identity server discovery response";
+    }
+
+    static get ERROR_MISSING_WELLKNOWN() {
+        return "No .well-known JSON file found";
+    }
+
+    static get ERROR_INVALID_JSON() {
+        return "Invalid JSON";
+    }
+
+    static get ALL_ERRORS() {
+        return [AutoDiscovery.ERROR_INVALID, AutoDiscovery.ERROR_GENERIC_FAILURE, AutoDiscovery.ERROR_INVALID_HS_BASE_URL, AutoDiscovery.ERROR_INVALID_HOMESERVER, AutoDiscovery.ERROR_INVALID_IS_BASE_URL, AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, AutoDiscovery.ERROR_INVALID_IS, AutoDiscovery.ERROR_MISSING_WELLKNOWN, AutoDiscovery.ERROR_INVALID_JSON];
+    }
+
+    /**
+     * The auto discovery failed. The client is expected to communicate
+     * the error to the user and refuse logging in.
+     * @return {string}
+     * @constructor
+     */
+    static get FAIL_ERROR() {
+        return "FAIL_ERROR";
+    }
+
+    /**
+     * The auto discovery failed, however the client may still recover
+     * from the problem. The client is recommended to that the same
+     * action it would for PROMPT while also warning the user about
+     * what went wrong. The client may also treat this the same as
+     * a FAIL_ERROR state.
+     * @return {string}
+     * @constructor
+     */
+    static get FAIL_PROMPT() {
+        return "FAIL_PROMPT";
+    }
+
+    /**
+     * The auto discovery didn't fail but did not find anything of
+     * interest. The client is expected to prompt the user for more
+     * information, or fail if it prefers.
+     * @return {string}
+     * @constructor
+     */
+    static get PROMPT() {
+        return "PROMPT";
+    }
+
+    /**
+     * The auto discovery was successful.
+     * @return {string}
+     * @constructor
+     */
+    static get SUCCESS() {
+        return "SUCCESS";
+    }
+
+    /**
+     * Validates and verifies client configuration information for purposes
+     * of logging in. Such information includes the homeserver URL
+     * and identity server URL the client would want. Additional details
+     * may also be included, and will be transparently brought into the
+     * response object unaltered.
+     * @param {string} wellknown The configuration object itself, as returned
+     * by the .well-known auto-discovery endpoint.
+     * @return {Promise<DiscoveredClientConfig>} Resolves to the verified
+     * configuration, which may include error states. Rejects on unexpected
+     * failure, not when verification fails.
+     */
+    static async fromDiscoveryConfig(wellknown) {
+        // Step 1 is to get the config, which is provided to us here.
+
+        // We default to an error state to make the first few checks easier to
+        // write. We'll update the properties of this object over the duration
+        // of this function.
+        const clientConfig = {
+            "m.homeserver": {
+                state: AutoDiscovery.FAIL_ERROR,
+                error: AutoDiscovery.ERROR_INVALID,
+                base_url: null
+            },
+            "m.identity_server": {
+                // Technically, we don't have a problem with the identity server
+                // config at this point.
+                state: AutoDiscovery.PROMPT,
+                error: null,
+                base_url: null
+            }
+        };
+
+        if (!wellknown || !wellknown["m.homeserver"]) {
+            _logger2.default.error("No m.homeserver key in config");
+
+            clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
+            clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID;
+
+            return _bluebird2.default.resolve(clientConfig);
+        }
+
+        if (!wellknown["m.homeserver"]["base_url"]) {
+            _logger2.default.error("No m.homeserver base_url in config");
+
+            clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
+            clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL;
+
+            return _bluebird2.default.resolve(clientConfig);
+        }
+
+        // Step 2: Make sure the homeserver URL is valid *looking*. We'll make
+        // sure it points to a homeserver in Step 3.
+        const hsUrl = this._sanitizeWellKnownUrl(wellknown["m.homeserver"]["base_url"]);
+        if (!hsUrl) {
+            _logger2.default.error("Invalid base_url for m.homeserver");
+            clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL;
+            return _bluebird2.default.resolve(clientConfig);
+        }
+
+        // Step 3: Make sure the homeserver URL points to a homeserver.
+        const hsVersions = await this._fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`);
+        if (!hsVersions || !hsVersions.raw["versions"]) {
+            _logger2.default.error("Invalid /versions response");
+            clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
+
+            // Supply the base_url to the caller because they may be ignoring liveliness
+            // errors, like this one.
+            clientConfig["m.homeserver"].base_url = hsUrl;
+
+            return _bluebird2.default.resolve(clientConfig);
+        }
+
+        // Step 4: Now that the homeserver looks valid, update our client config.
+        clientConfig["m.homeserver"] = {
+            state: AutoDiscovery.SUCCESS,
+            error: null,
+            base_url: hsUrl
+        };
+
+        // Step 5: Try to pull out the identity server configuration
+        let isUrl = "";
+        if (wellknown["m.identity_server"]) {
+            // We prepare a failing identity server response to save lines later
+            // in this branch. Note that we also fail the homeserver check in the
+            // object because according to the spec we're supposed to FAIL_ERROR
+            // if *anything* goes wrong with the IS validation, including invalid
+            // format. This means we're supposed to stop discovery completely.
+            const failingClientConfig = {
+                "m.homeserver": {
+                    state: AutoDiscovery.FAIL_ERROR,
+                    error: AutoDiscovery.ERROR_INVALID_IS,
+
+                    // We'll provide the base_url that was previously valid for
+                    // debugging purposes.
+                    base_url: clientConfig["m.homeserver"].base_url
+                },
+                "m.identity_server": {
+                    state: AutoDiscovery.FAIL_ERROR,
+                    error: AutoDiscovery.ERROR_INVALID_IS,
+                    base_url: null
+                }
+            };
+
+            // Step 5a: Make sure the URL is valid *looking*. We'll make sure it
+            // points to an identity server in Step 5b.
+            isUrl = this._sanitizeWellKnownUrl(wellknown["m.identity_server"]["base_url"]);
+            if (!isUrl) {
+                _logger2.default.error("Invalid base_url for m.identity_server");
+                failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IS_BASE_URL;
+                return _bluebird2.default.resolve(failingClientConfig);
+            }
+
+            // Step 5b: Verify there is an identity server listening on the provided
+            // URL.
+            const isResponse = await this._fetchWellKnownObject(`${isUrl}/_matrix/identity/api/v1`);
+            if (!isResponse || !isResponse.raw || isResponse.action !== "SUCCESS") {
+                _logger2.default.error("Invalid /api/v1 response");
+                failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
+
+                // Supply the base_url to the caller because they may be ignoring
+                // liveliness errors, like this one.
+                failingClientConfig["m.identity_server"].base_url = isUrl;
+
+                return _bluebird2.default.resolve(failingClientConfig);
+            }
+        }
+
+        // Step 6: Now that the identity server is valid, or never existed,
+        // populate the IS section.
+        if (isUrl && isUrl.length > 0) {
+            clientConfig["m.identity_server"] = {
+                state: AutoDiscovery.SUCCESS,
+                error: null,
+                base_url: isUrl
+            };
+        }
+
+        // Step 7: Copy any other keys directly into the clientConfig. This is for
+        // things like custom configuration of services.
+        Object.keys(wellknown).map(k => {
+            if (k === "m.homeserver" || k === "m.identity_server") {
+                // Only copy selected parts of the config to avoid overwriting
+                // properties computed by the validation logic above.
+                const notProps = ["error", "state", "base_url"];
+                for (const prop of Object.keys(wellknown[k])) {
+                    if (notProps.includes(prop)) continue;
+                    clientConfig[k][prop] = wellknown[k][prop];
+                }
+            } else {
+                // Just copy the whole thing over otherwise
+                clientConfig[k] = wellknown[k];
+            }
+        });
+
+        // Step 8: Give the config to the caller (finally)
+        return _bluebird2.default.resolve(clientConfig);
+    }
+
+    /**
+     * Attempts to automatically discover client configuration information
+     * prior to logging in. Such information includes the homeserver URL
+     * and identity server URL the client would want. Additional details
+     * may also be discovered, and will be transparently included in the
+     * response object unaltered.
+     * @param {string} domain The homeserver domain to perform discovery
+     * on. For example, "matrix.org".
+     * @return {Promise<DiscoveredClientConfig>} Resolves to the discovered
+     * configuration, which may include error states. Rejects on unexpected
+     * failure, not when discovery fails.
+     */
+    static async findClientConfig(domain) {
+        if (!domain || typeof domain !== "string" || domain.length === 0) {
+            throw new Error("'domain' must be a string of non-zero length");
+        }
+
+        // We use a .well-known lookup for all cases. According to the spec, we
+        // can do other discovery mechanisms if we want such as custom lookups
+        // however we won't bother with that here (mostly because the spec only
+        // supports .well-known right now).
+        //
+        // By using .well-known, we need to ensure we at least pull out a URL
+        // for the homeserver. We don't really need an identity server configuration
+        // but will return one anyways (with state PROMPT) to make development
+        // easier for clients. If we can't get a homeserver URL, all bets are
+        // off on the rest of the config and we'll assume it is invalid too.
+
+        // We default to an error state to make the first few checks easier to
+        // write. We'll update the properties of this object over the duration
+        // of this function.
+        const clientConfig = {
+            "m.homeserver": {
+                state: AutoDiscovery.FAIL_ERROR,
+                error: AutoDiscovery.ERROR_INVALID,
+                base_url: null
+            },
+            "m.identity_server": {
+                // Technically, we don't have a problem with the identity server
+                // config at this point.
+                state: AutoDiscovery.PROMPT,
+                error: null,
+                base_url: null
+            }
+        };
+
+        // Step 1: Actually request the .well-known JSON file and make sure it
+        // at least has a homeserver definition.
+        const wellknown = await this._fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
+        if (!wellknown || wellknown.action !== "SUCCESS") {
+            _logger2.default.error("No response or error when parsing .well-known");
+            if (wellknown.reason) _logger2.default.error(wellknown.reason);
+            if (wellknown.action === "IGNORE") {
+                clientConfig["m.homeserver"] = {
+                    state: AutoDiscovery.PROMPT,
+                    error: null,
+                    base_url: null
+                };
+            } else {
+                // this can only ever be FAIL_PROMPT at this point.
+                clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
+                clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID;
+            }
+            return _bluebird2.default.resolve(clientConfig);
+        }
+
+        // Step 2: Validate and parse the config
+        return AutoDiscovery.fromDiscoveryConfig(wellknown.raw);
+    }
+
+    /**
+     * Gets the raw discovery client configuration for the given domain name.
+     * Should only be used if there's no validation to be done on the resulting
+     * object, otherwise use findClientConfig().
+     * @param {string} domain The domain to get the client config for.
+     * @returns {Promise<object>} Resolves to the domain's client config. Can
+     * be an empty object.
+     */
+    static async getRawClientConfig(domain) {
+        if (!domain || typeof domain !== "string" || domain.length === 0) {
+            throw new Error("'domain' must be a string of non-zero length");
+        }
+
+        const response = await this._fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
+        if (!response) return {};
+        return response.raw || {};
+    }
+
+    /**
+     * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and
+     * is suitable for the requirements laid out by .well-known auto discovery.
+     * If valid, the URL will also be stripped of any trailing slashes.
+     * @param {string} url The potentially invalid URL to sanitize.
+     * @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid.
+     * @private
+     */
+    static _sanitizeWellKnownUrl(url) {
+        if (!url) return false;
+
+        try {
+            // We have to try and parse the URL using the NodeJS URL
+            // library if we're on NodeJS and use the browser's URL
+            // library when we're in a browser. To accomplish this, we
+            // try the NodeJS version first and fall back to the browser.
+            let parsed = null;
+            try {
+                if (_url.URL) parsed = new _url.URL(url);else parsed = new URL(url);
+            } catch (e) {
+                parsed = new URL(url);
+            }
+
+            if (!parsed || !parsed.hostname) return false;
+            if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
+
+            const port = parsed.port ? `:${parsed.port}` : "";
+            const path = parsed.pathname ? parsed.pathname : "";
+            let saferUrl = `${parsed.protocol}//${parsed.hostname}${port}${path}`;
+            if (saferUrl.endsWith("/")) {
+                saferUrl = saferUrl.substring(0, saferUrl.length - 1);
+            }
+            return saferUrl;
+        } catch (e) {
+            _logger2.default.error(e);
+            return false;
+        }
+    }
+
+    /**
+     * Fetches a JSON object from a given URL, as expected by all .well-known
+     * related lookups. If the server gives a 404 then the `action` will be
+     * IGNORE. If the server returns something that isn't JSON, the `action`
+     * will be FAIL_PROMPT. For any other failure the `action` will be FAIL_PROMPT.
+     *
+     * The returned object will be a result of the call in object form with
+     * the following properties:
+     *   raw: The JSON object returned by the server.
+     *   action: One of SUCCESS, IGNORE, or FAIL_PROMPT.
+     *   reason: Relatively human readable description of what went wrong.
+     *   error: The actual Error, if one exists.
+     * @param {string} url The URL to fetch a JSON object from.
+     * @return {Promise<object>} Resolves to the returned state.
+     * @private
+     */
+    static async _fetchWellKnownObject(url) {
+        return new _bluebird2.default(function (resolve, reject) {
+            const request = require("./matrix").getRequest();
+            if (!request) throw new Error("No request library available");
+            request({ method: "GET", uri: url, timeout: 5000 }, (err, response, body) => {
+                if (err || response.statusCode < 200 || response.statusCode >= 300) {
+                    let action = "FAIL_PROMPT";
+                    let reason = (err ? err.message : null) || "General failure";
+                    if (response.statusCode === 404) {
+                        action = "IGNORE";
+                        reason = AutoDiscovery.ERROR_MISSING_WELLKNOWN;
+                    }
+                    resolve({ raw: {}, action: action, reason: reason, error: err });
+                    return;
+                }
+
+                try {
+                    resolve({ raw: JSON.parse(body), action: "SUCCESS" });
+                } catch (e) {
+                    let reason = AutoDiscovery.ERROR_INVALID;
+                    if (e.name === "SyntaxError") {
+                        reason = AutoDiscovery.ERROR_INVALID_JSON;
+                    }
+                    resolve({
+                        raw: {},
+                        action: "FAIL_PROMPT",
+                        reason: reason,
+                        error: e
+                    });
+                }
+            });
+        });
+    }
+}
+exports.AutoDiscovery = AutoDiscovery;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/base-apis.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/base-apis.js
@@ -1,10 +1,13 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -17,18 +20,38 @@ limitations under the License.
 
 /**
  * This is an internal module. MatrixBaseApis is currently only meant to be used
  * by {@link client~MatrixClient}.
  *
  * @module base-apis
  */
 
-var httpApi = require("./http-api");
-var utils = require("./utils");
+var _serviceTypes = require('./service-types');
+
+var _logger = require('./logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const httpApi = require("./http-api");
+const utils = require("./utils");
+const PushProcessor = require("./pushprocessor");
+
+function termsUrlForService(serviceType, baseUrl) {
+    switch (serviceType) {
+        case _serviceTypes.SERVICE_TYPES.IS:
+            return baseUrl + httpApi.PREFIX_IDENTITY_V2 + '/terms';
+        case _serviceTypes.SERVICE_TYPES.IM:
+            return baseUrl + '/_matrix/integrations/v1/terms';
+        default:
+            throw new Error('Unsupported service type');
+    }
+}
 
 /**
  * Low-level wrappers for the Matrix APIs
  *
  * @constructor
  *
  * @param {Object} opts Configuration options
  *
@@ -40,278 +63,381 @@ var utils = require("./utils");
  *
  * @param {Function} opts.request Required. The function to invoke for HTTP
  * requests. The value of this property is typically <code>require("request")
  * </code> as it returns a function which meets the required interface. See
  * {@link requestFunction} for more information.
  *
  * @param {string} opts.accessToken The access_token for this user.
  *
+ * @param {IdentityServerProvider} [opts.identityServer]
+ * Optional. A provider object with one function `getAccessToken`, which is a
+ * callback that returns a Promise<String> of an identity access token to supply
+ * with identity requests. If the object is unset, no access token will be
+ * supplied.
+ * See also https://github.com/vector-im/riot-web/issues/10615 which seeks to
+ * replace the previous approach of manual access tokens params with this
+ * callback throughout the SDK.
+ *
+ * @param {Number=} opts.localTimeoutMs Optional. The default maximum amount of
+ * time to wait before timing out HTTP requests. If not specified, there is no
+ * timeout.
+ *
  * @param {Object} opts.queryParams Optional. Extra query parameters to append
  * to all requests with this client. Useful for application services which require
  * <code>?user_id=</code>.
  *
+ * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use
+ * Authorization header instead of query param to send the access token to the server.
  */
 function MatrixBaseApis(opts) {
     utils.checkObjectHasKeys(opts, ["baseUrl", "request"]);
 
     this.baseUrl = opts.baseUrl;
     this.idBaseUrl = opts.idBaseUrl;
+    this.identityServer = opts.identityServer;
 
-    var httpOpts = {
+    const httpOpts = {
         baseUrl: opts.baseUrl,
         idBaseUrl: opts.idBaseUrl,
         accessToken: opts.accessToken,
         request: opts.request,
         prefix: httpApi.PREFIX_R0,
         onlyData: true,
-        extraParams: opts.queryParams
+        extraParams: opts.queryParams,
+        localTimeoutMs: opts.localTimeoutMs,
+        useAuthorizationHeader: opts.useAuthorizationHeader
     };
     this._http = new httpApi.MatrixHttpApi(this, httpOpts);
 
     this._txnCtr = 0;
 }
 
 /**
  * Get the Homeserver URL of this client
  * @return {string} Homeserver URL of this client
  */
-MatrixBaseApis.prototype.getHomeserverUrl = function() {
+MatrixBaseApis.prototype.getHomeserverUrl = function () {
     return this.baseUrl;
 };
 
 /**
  * Get the Identity Server URL of this client
+ * @param {boolean} stripProto whether or not to strip the protocol from the URL
  * @return {string} Identity Server URL of this client
  */
-MatrixBaseApis.prototype.getIdentityServerUrl = function() {
+MatrixBaseApis.prototype.getIdentityServerUrl = function (stripProto = false) {
+    if (stripProto && (this.idBaseUrl.startsWith("http://") || this.idBaseUrl.startsWith("https://"))) {
+        return this.idBaseUrl.split("://")[1];
+    }
     return this.idBaseUrl;
 };
 
 /**
+ * Set the Identity Server URL of this client
+ * @param {string} url New Identity Server URL
+ */
+MatrixBaseApis.prototype.setIdentityServerUrl = function (url) {
+    this.idBaseUrl = utils.ensureNoTrailingSlash(url);
+    this._http.setIdBaseUrl(this.idBaseUrl);
+};
+
+/**
  * Get the access token associated with this account.
  * @return {?String} The access_token or null
  */
-MatrixBaseApis.prototype.getAccessToken = function() {
+MatrixBaseApis.prototype.getAccessToken = function () {
     return this._http.opts.accessToken || null;
 };
 
 /**
  * @return {boolean} true if there is a valid access_token for this client.
  */
-MatrixBaseApis.prototype.isLoggedIn = function() {
+MatrixBaseApis.prototype.isLoggedIn = function () {
     return this._http.opts.accessToken !== undefined;
 };
 
 /**
  * Make up a new transaction id
  *
  * @return {string} a new, unique, transaction id
  */
-MatrixBaseApis.prototype.makeTxnId = function() {
-    return "m" + new Date().getTime() + "." + (this._txnCtr++);
+MatrixBaseApis.prototype.makeTxnId = function () {
+    return "m" + new Date().getTime() + "." + this._txnCtr++;
 };
 
-
 // Registration/Login operations
 // =============================
 
 /**
+ * Check whether a username is available prior to registration. An error response
+ * indicates an invalid/unavailable username.
+ * @param {string} username The username to check the availability of.
+ * @return {module:client.Promise} Resolves: to `true`.
+ */
+MatrixBaseApis.prototype.isUsernameAvailable = function (username) {
+    return this._http.authedRequest(undefined, "GET", '/register/available', { username: username }).then(response => {
+        return response.available;
+    });
+};
+
+/**
  * @param {string} username
  * @param {string} password
  * @param {string} sessionId
  * @param {Object} auth
- * @param {boolean} bindEmail
+ * @param {Object} bindThreepids Set key 'email' to true to bind any email
+ *     threepid uses during registration in the ID server. Set 'msisdn' to
+ *     true to bind msisdn.
  * @param {string} guestAccessToken
+ * @param {string} inhibitLogin
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.register = function(
-    username, password,
-    sessionId, auth, bindEmail, guestAccessToken,
-    callback
-) {
-    if (auth === undefined) { auth = {}; }
-    if (sessionId) { auth.session = sessionId; }
+MatrixBaseApis.prototype.register = function (username, password, sessionId, auth, bindThreepids, guestAccessToken, inhibitLogin, callback) {
+    // backwards compat
+    if (bindThreepids === true) {
+        bindThreepids = { email: true };
+    } else if (bindThreepids === null || bindThreepids === undefined) {
+        bindThreepids = {};
+    }
+    if (typeof inhibitLogin === 'function') {
+        callback = inhibitLogin;
+        inhibitLogin = undefined;
+    }
 
-    var params = {
+    if (auth === undefined || auth === null) {
+        auth = {};
+    }
+    if (sessionId) {
+        auth.session = sessionId;
+    }
+
+    const params = {
         auth: auth
     };
-    if (username !== undefined && username !== null) { params.username = username; }
-    if (password !== undefined && password !== null) { params.password = password; }
-    if (bindEmail !== undefined && bindEmail !== null) { params.bind_email = bindEmail; }
+    if (username !== undefined && username !== null) {
+        params.username = username;
+    }
+    if (password !== undefined && password !== null) {
+        params.password = password;
+    }
+    if (bindThreepids.email) {
+        params.bind_email = true;
+    }
+    if (bindThreepids.msisdn) {
+        params.bind_msisdn = true;
+    }
     if (guestAccessToken !== undefined && guestAccessToken !== null) {
         params.guest_access_token = guestAccessToken;
     }
+    if (inhibitLogin !== undefined && inhibitLogin !== null) {
+        params.inhibit_login = inhibitLogin;
+    }
+    // Temporary parameter added to make the register endpoint advertise
+    // msisdn flows. This exists because there are clients that break
+    // when given stages they don't recognise. This parameter will cease
+    // to be necessary once these old clients are gone.
+    // Only send it if we send any params at all (the password param is
+    // mandatory, so if we send any params, we'll send the password param)
+    if (password !== undefined && password !== null) {
+        params.x_show_msisdn = true;
+    }
 
     return this.registerRequest(params, undefined, callback);
 };
 
 /**
  * Register a guest account.
  * @param {Object=} opts Registration options
  * @param {Object} opts.body JSON HTTP body to provide.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.registerGuest = function(opts, callback) {
+MatrixBaseApis.prototype.registerGuest = function (opts, callback) {
     opts = opts || {};
     opts.body = opts.body || {};
     return this.registerRequest(opts.body, "guest", callback);
 };
 
 /**
  * @param {Object} data   parameters for registration request
  * @param {string=} kind  type of user to register. may be "guest"
  * @param {module:client.callback=} callback
  * @return {module:client.Promise} Resolves: to the /register response
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.registerRequest = function(data, kind, callback) {
-    var params = {};
-    if (kind) { params.kind = kind; }
+MatrixBaseApis.prototype.registerRequest = function (data, kind, callback) {
+    const params = {};
+    if (kind) {
+        params.kind = kind;
+    }
 
-    return this._http.request(
-        callback, "POST", "/register", params, data
-    );
+    return this._http.request(callback, "POST", "/register", params, data);
 };
 
 /**
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.loginFlows = function(callback) {
+MatrixBaseApis.prototype.loginFlows = function (callback) {
     return this._http.request(callback, "GET", "/login");
 };
 
 /**
  * @param {string} loginType
  * @param {Object} data
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.login = function(loginType, data, callback) {
-    var login_data = {
-        type: loginType,
+MatrixBaseApis.prototype.login = function (loginType, data, callback) {
+    const login_data = {
+        type: loginType
     };
 
     // merge data into login_data
     utils.extend(login_data, data);
 
-    return this._http.authedRequest(
-        callback, "POST", "/login", undefined, login_data
-    );
+    return this._http.authedRequest((error, response) => {
+        if (response && response.access_token && response.user_id) {
+            this._http.opts.accessToken = response.access_token;
+            this.credentials = {
+                userId: response.user_id
+            };
+        }
+
+        if (callback) {
+            callback(error, response);
+        }
+    }, "POST", "/login", undefined, login_data);
 };
 
 /**
  * @param {string} user
  * @param {string} password
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.loginWithPassword = function(user, password, callback) {
+MatrixBaseApis.prototype.loginWithPassword = function (user, password, callback) {
     return this.login("m.login.password", {
         user: user,
         password: password
     }, callback);
 };
 
 /**
  * @param {string} relayState URL Callback after SAML2 Authentication
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.loginWithSAML2 = function(relayState, callback) {
+MatrixBaseApis.prototype.loginWithSAML2 = function (relayState, callback) {
     return this.login("m.login.saml2", {
         relay_state: relayState
     }, callback);
 };
 
 /**
  * @param {string} redirectUrl The URL to redirect to after the HS
  * authenticates with CAS.
  * @return {string} The HS URL to hit to begin the CAS login process.
  */
-MatrixBaseApis.prototype.getCasLoginUrl = function(redirectUrl) {
-    return this._http.getUrl("/login/cas/redirect", {
+MatrixBaseApis.prototype.getCasLoginUrl = function (redirectUrl) {
+    return this.getSsoLoginUrl(redirectUrl, "cas");
+};
+
+/**
+ * @param {string} redirectUrl The URL to redirect to after the HS
+ *     authenticates with the SSO.
+ * @param {string} loginType The type of SSO login we are doing (sso or cas).
+ *     Defaults to 'sso'.
+ * @return {string} The HS URL to hit to begin the SSO login process.
+ */
+MatrixBaseApis.prototype.getSsoLoginUrl = function (redirectUrl, loginType) {
+    if (loginType === undefined) {
+        loginType = "sso";
+    }
+    return this._http.getUrl("/login/" + loginType + "/redirect", {
         "redirectUrl": redirectUrl
-    }, httpApi.PREFIX_UNSTABLE);
+    }, httpApi.PREFIX_R0);
 };
 
 /**
  * @param {string} token Login token previously received from homeserver
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.loginWithToken = function(token, callback) {
+MatrixBaseApis.prototype.loginWithToken = function (token, callback) {
     return this.login("m.login.token", {
         token: token
     }, callback);
 };
 
-
 /**
  * Logs out the current session.
  * Obviously, further calls that require authorisation should fail after this
  * method is called. The state of the MatrixClient object is not affected:
  * it is up to the caller to either reset or destroy the MatrixClient after
  * this method succeeds.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: On success, the empty object
  */
-MatrixBaseApis.prototype.logout = function(callback) {
-    return this._http.authedRequest(
-        callback, "POST", '/logout'
-    );
+MatrixBaseApis.prototype.logout = function (callback) {
+    return this._http.authedRequest(callback, "POST", '/logout');
 };
 
 /**
  * Deactivates the logged-in account.
  * Obviously, further calls that require authorisation should fail after this
  * method is called. The state of the MatrixClient object is not affected:
  * it is up to the caller to either reset or destroy the MatrixClient after
  * this method succeeds.
  * @param {object} auth Optional. Auth data to supply for User-Interactive auth.
- * @param {module:client.callback} callback Optional.
+ * @param {boolean} erase Optional. If set, send as `erase` attribute in the
+ * JSON request body, indicating whether the account should be erased. Defaults
+ * to false.
  * @return {module:client.Promise} Resolves: On success, the empty object
  */
-MatrixBaseApis.prototype.deactivateAccount = function(auth, callback) {
-    var body = {};
+MatrixBaseApis.prototype.deactivateAccount = function (auth, erase) {
+    if (typeof erase === 'function') {
+        throw new Error('deactivateAccount no longer accepts a callback parameter');
+    }
+
+    const body = {};
     if (auth) {
-        body = {
-            auth: auth,
-        };
+        body.auth = auth;
     }
-    return this._http.authedRequestWithPrefix(
-        callback, "POST", '/account/deactivate', undefined, body, httpApi.PREFIX_UNSTABLE
-    );
+    if (erase !== undefined) {
+        body.erase = erase;
+    }
+
+    return this._http.authedRequest(undefined, "POST", '/account/deactivate', undefined, body);
 };
 
 /**
  * Get the fallback URL to use for unknown interactive-auth stages.
  *
  * @param {string} loginType     the type of stage being attempted
  * @param {string} authSessionId the auth session ID provided by the homeserver
  *
  * @return {string} HS URL to hit to for the fallback interface
  */
-MatrixBaseApis.prototype.getFallbackAuthUrl = function(loginType, authSessionId) {
-    var path = utils.encodeUri("/auth/$loginType/fallback/web", {
-        $loginType: loginType,
+MatrixBaseApis.prototype.getFallbackAuthUrl = function (loginType, authSessionId) {
+    const path = utils.encodeUri("/auth/$loginType/fallback/web", {
+        $loginType: loginType
     });
 
     return this._http.getUrl(path, {
-        session: authSessionId,
+        session: authSessionId
     }, httpApi.PREFIX_R0);
 };
 
 // Room operations
 // ===============
 
 /**
  * Create a new room.
@@ -322,601 +448,1095 @@ MatrixBaseApis.prototype.getFallbackAuth
  * @param {string[]} options.invite A list of user IDs to invite to this room.
  * @param {string} options.name The name to give this room.
  * @param {string} options.topic The topic to give this room.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: <code>{room_id: {string},
  * room_alias: {string(opt)}}</code>
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.createRoom = function(options, callback) {
+MatrixBaseApis.prototype.createRoom = function (options, callback) {
     // valid options include: room_alias_name, visibility, invite
-    return this._http.authedRequest(
-        callback, "POST", "/createRoom", undefined, options
-    );
+    return this._http.authedRequest(callback, "POST", "/createRoom", undefined, options);
+};
+/**
+ * Fetches relations for a given event
+ * @param {string} roomId the room of the event
+ * @param {string} eventId the id of the event
+ * @param {string} relationType the rel_type of the relations requested
+ * @param {string} eventType the event type of the relations requested
+ * @param {Object} opts options with optional values for the request.
+ * @param {Object} opts.from the pagination token returned from a previous request as `next_batch` to return following relations.
+ * @return {Object} the response, with chunk and next_batch.
+ */
+MatrixBaseApis.prototype.fetchRelations = async function (roomId, eventId, relationType, eventType, opts) {
+    const queryParams = {};
+    if (opts.from) {
+        queryParams.from = opts.from;
+    }
+    const queryString = utils.encodeParams(queryParams);
+    const path = utils.encodeUri("/rooms/$roomId/relations/$eventId/$relationType/$eventType?" + queryString, {
+        $roomId: roomId,
+        $eventId: eventId,
+        $relationType: relationType,
+        $eventType: eventType
+    });
+    const response = await this._http.authedRequest(undefined, "GET", path, null, null, {
+        prefix: httpApi.PREFIX_UNSTABLE
+    });
+    return response;
 };
 
 /**
  * @param {string} roomId
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.roomState = function(roomId, callback) {
-    var path = utils.encodeUri("/rooms/$roomId/state", {$roomId: roomId});
+MatrixBaseApis.prototype.roomState = function (roomId, callback) {
+    const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId });
+    return this._http.authedRequest(callback, "GET", path);
+};
+
+/**
+ * Get an event in a room by its event id.
+ * @param {string} roomId
+ * @param {string} eventId
+ * @param {module:client.callback} callback Optional.
+ *
+ * @return {Promise} Resolves to an object containing the event.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.fetchRoomEvent = function (roomId, eventId, callback) {
+    const path = utils.encodeUri("/rooms/$roomId/event/$eventId", {
+        $roomId: roomId,
+        $eventId: eventId
+    });
+    return this._http.authedRequest(callback, "GET", path);
+};
+
+/**
+ * @param {string} roomId
+ * @param {string} includeMembership the membership type to include in the response
+ * @param {string} excludeMembership the membership type to exclude from the response
+ * @param {string} atEventId the id of the event for which moment in the timeline the members should be returned for
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: dictionary of userid to profile information
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.members = function (roomId, includeMembership, excludeMembership, atEventId, callback) {
+    const queryParams = {};
+    if (includeMembership) {
+        queryParams.membership = includeMembership;
+    }
+    if (excludeMembership) {
+        queryParams.not_membership = excludeMembership;
+    }
+    if (atEventId) {
+        queryParams.at = atEventId;
+    }
+
+    const queryString = utils.encodeParams(queryParams);
+
+    const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, { $roomId: roomId });
     return this._http.authedRequest(callback, "GET", path);
 };
 
 /**
+ * Upgrades a room to a new protocol version
+ * @param {string} roomId
+ * @param {string} newVersion The target version to upgrade to
+ * @return {module:client.Promise} Resolves: Object with key 'replacement_room'
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.upgradeRoom = function (roomId, newVersion) {
+    const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId });
+    return this._http.authedRequest(undefined, "POST", path, undefined, { new_version: newVersion });
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Group summary object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getGroupSummary = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/summary", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Group profile object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getGroupProfile = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/profile", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * @param {string} groupId
+ * @param {Object} profile The group profile object
+ * @param {string=} profile.name Name of the group
+ * @param {string=} profile.avatar_url MXC avatar URL
+ * @param {string=} profile.short_description A short description of the room
+ * @param {string=} profile.long_description A longer HTML description of the room
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.setGroupProfile = function (groupId, profile) {
+    const path = utils.encodeUri("/groups/$groupId/profile", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "POST", path, undefined, profile);
+};
+
+/**
+ * @param {string} groupId
+ * @param {object} policy The join policy for the group. Must include at
+ *     least a 'type' field which is 'open' if anyone can join the group
+ *     the group without prior approval, or 'invite' if an invite is
+ *     required to join.
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.setGroupJoinPolicy = function (groupId, policy) {
+    const path = utils.encodeUri("/groups/$groupId/settings/m.join_policy", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {
+        'm.join_policy': policy
+    });
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Group users list object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getGroupUsers = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/users", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Group users list object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getGroupInvitedUsers = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/invited_users", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Group rooms list object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getGroupRooms = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/rooms", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} userId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.inviteUserToGroup = function (groupId, userId) {
+    const path = utils.encodeUri("/groups/$groupId/admin/users/invite/$userId", { $groupId: groupId, $userId: userId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} userId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.removeUserFromGroup = function (groupId, userId) {
+    const path = utils.encodeUri("/groups/$groupId/admin/users/remove/$userId", { $groupId: groupId, $userId: userId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} userId
+ * @param {string} roleId Optional.
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.addUserToGroupSummary = function (groupId, userId, roleId) {
+    const path = utils.encodeUri(roleId ? "/groups/$groupId/summary/$roleId/users/$userId" : "/groups/$groupId/summary/users/$userId", { $groupId: groupId, $roleId: roleId, $userId: userId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} userId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.removeUserFromGroupSummary = function (groupId, userId) {
+    const path = utils.encodeUri("/groups/$groupId/summary/users/$userId", { $groupId: groupId, $userId: userId });
+    return this._http.authedRequest(undefined, "DELETE", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} roomId
+ * @param {string} categoryId Optional.
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.addRoomToGroupSummary = function (groupId, roomId, categoryId) {
+    const path = utils.encodeUri(categoryId ? "/groups/$groupId/summary/$categoryId/rooms/$roomId" : "/groups/$groupId/summary/rooms/$roomId", { $groupId: groupId, $categoryId: categoryId, $roomId: roomId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} roomId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.removeRoomFromGroupSummary = function (groupId, roomId) {
+    const path = utils.encodeUri("/groups/$groupId/summary/rooms/$roomId", { $groupId: groupId, $roomId: roomId });
+    return this._http.authedRequest(undefined, "DELETE", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} roomId
+ * @param {bool} isPublic Whether the room-group association is visible to non-members
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.addRoomToGroup = function (groupId, roomId, isPublic) {
+    if (isPublic === undefined) {
+        isPublic = true;
+    }
+    const path = utils.encodeUri("/groups/$groupId/admin/rooms/$roomId", { $groupId: groupId, $roomId: roomId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, { "m.visibility": { type: isPublic ? "public" : "private" } });
+};
+
+/**
+ * Configure the visibility of a room-group association.
+ * @param {string} groupId
+ * @param {string} roomId
+ * @param {bool} isPublic Whether the room-group association is visible to non-members
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.updateGroupRoomVisibility = function (groupId, roomId, isPublic) {
+    // NB: The /config API is generic but there's not much point in exposing this yet as synapse
+    //     is the only server to implement this. In future we should consider an API that allows
+    //     arbitrary configuration, i.e. "config/$configKey".
+
+    const path = utils.encodeUri("/groups/$groupId/admin/rooms/$roomId/config/m.visibility", { $groupId: groupId, $roomId: roomId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, { type: isPublic ? "public" : "private" });
+};
+
+/**
+ * @param {string} groupId
+ * @param {string} roomId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.removeRoomFromGroup = function (groupId, roomId) {
+    const path = utils.encodeUri("/groups/$groupId/admin/rooms/$roomId", { $groupId: groupId, $roomId: roomId });
+    return this._http.authedRequest(undefined, "DELETE", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @param {Object} opts Additional options to send alongside the acceptance.
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.acceptGroupInvite = function (groupId, opts = null) {
+    const path = utils.encodeUri("/groups/$groupId/self/accept_invite", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, opts || {});
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.joinGroup = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/self/join", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.leaveGroup = function (groupId) {
+    const path = utils.encodeUri("/groups/$groupId/self/leave", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @return {module:client.Promise} Resolves: The groups to which the user is joined
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getJoinedGroups = function () {
+    const path = utils.encodeUri("/joined_groups");
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * @param {Object} content Request content
+ * @param {string} content.localpart The local part of the desired group ID
+ * @param {Object} content.profile Group profile object
+ * @return {module:client.Promise} Resolves: Object with key group_id: id of the created group
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.createGroup = function (content) {
+    const path = utils.encodeUri("/create_group");
+    return this._http.authedRequest(undefined, "POST", path, undefined, content);
+};
+
+/**
+ * @param {string[]} userIds List of user IDs
+ * @return {module:client.Promise} Resolves: Object as exmaple below
+ *
+ *     {
+ *         "users": {
+ *             "@bob:example.com": {
+ *                 "+example:example.com"
+ *             }
+ *         }
+ *     }
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getPublicisedGroups = function (userIds) {
+    const path = utils.encodeUri("/publicised_groups");
+    return this._http.authedRequest(undefined, "POST", path, undefined, { user_ids: userIds });
+};
+
+/**
+ * @param {string} groupId
+ * @param {bool} isPublic Whether the user's membership of this group is made public
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.setGroupPublicity = function (groupId, isPublic) {
+    const path = utils.encodeUri("/groups/$groupId/self/update_publicity", { $groupId: groupId });
+    return this._http.authedRequest(undefined, "PUT", path, undefined, {
+        publicise: isPublic
+    });
+};
+
+/**
  * Retrieve a state event.
  * @param {string} roomId
  * @param {string} eventType
  * @param {string} stateKey
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getStateEvent = function(roomId, eventType, stateKey, callback) {
-    var pathParams = {
+MatrixBaseApis.prototype.getStateEvent = function (roomId, eventType, stateKey, callback) {
+    const pathParams = {
         $roomId: roomId,
         $eventType: eventType,
         $stateKey: stateKey
     };
-    var path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
+    let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
     if (stateKey !== undefined) {
         path = utils.encodeUri(path + "/$stateKey", pathParams);
     }
-    return this._http.authedRequest(
-        callback, "GET", path
-    );
+    return this._http.authedRequest(callback, "GET", path);
 };
 
 /**
  * @param {string} roomId
  * @param {string} eventType
  * @param {Object} content
  * @param {string} stateKey
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.sendStateEvent = function(roomId, eventType, content, stateKey,
-                                                 callback) {
-    var pathParams = {
+MatrixBaseApis.prototype.sendStateEvent = function (roomId, eventType, content, stateKey, callback) {
+    const pathParams = {
         $roomId: roomId,
         $eventType: eventType,
         $stateKey: stateKey
     };
-    var path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
+    let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
     if (stateKey !== undefined) {
         path = utils.encodeUri(path + "/$stateKey", pathParams);
     }
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, content
-    );
-};
-
-/**
- * @param {string} roomId
- * @param {string} eventId
- * @param {module:client.callback} callback Optional.
- * @return {module:client.Promise} Resolves: TODO
- * @return {module:http-api.MatrixError} Rejects: with an error response.
- */
-MatrixBaseApis.prototype.redactEvent = function(roomId, eventId, callback) {
-    var path = utils.encodeUri("/rooms/$roomId/redact/$eventId", {
-        $roomId: roomId,
-        $eventId: eventId
-    });
-    return this._http.authedRequest(callback, "POST", path, undefined, {});
+    return this._http.authedRequest(callback, "PUT", path, undefined, content);
 };
 
 /**
  * @param {string} roomId
  * @param {Number} limit
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.roomInitialSync = function(roomId, limit, callback) {
-    if (utils.isFunction(limit)) { callback = limit; limit = undefined; }
-    var path = utils.encodeUri("/rooms/$roomId/initialSync",
-        {$roomId: roomId}
-    );
+MatrixBaseApis.prototype.roomInitialSync = function (roomId, limit, callback) {
+    if (utils.isFunction(limit)) {
+        callback = limit;limit = undefined;
+    }
+    const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId });
     if (!limit) {
         limit = 30;
     }
-    return this._http.authedRequest(
-        callback, "GET", path, { limit: limit }
-    );
+    return this._http.authedRequest(callback, "GET", path, { limit: limit });
 };
 
+/**
+ * Set a marker to indicate the point in a room before which the user has read every
+ * event. This can be retrieved from room account data (the event type is `m.fully_read`)
+ * and displayed as a horizontal line in the timeline that is visually distinct to the
+ * position of the user's own read receipt.
+ * @param {string} roomId ID of the room that has been read
+ * @param {string} rmEventId ID of the event that has been read
+ * @param {string} rrEventId ID of the event tracked by the read receipt. This is here
+ * for convenience because the RR and the RM are commonly updated at the same time as
+ * each other. Optional.
+ * @param {object} opts Options for the read markers.
+ * @param {object} opts.hidden True to hide the read receipt from other users. <b>This
+ * property is currently unstable and may change in the future.</b>
+ * @return {module:client.Promise} Resolves: the empty object, {}.
+ */
+MatrixBaseApis.prototype.setRoomReadMarkersHttpRequest = function (roomId, rmEventId, rrEventId, opts) {
+    const path = utils.encodeUri("/rooms/$roomId/read_markers", {
+        $roomId: roomId
+    });
+
+    const content = {
+        "m.fully_read": rmEventId,
+        "m.read": rrEventId,
+        "m.hidden": Boolean(opts ? opts.hidden : false)
+    };
+
+    return this._http.authedRequest(undefined, "POST", path, undefined, content);
+};
+
+/**
+ * @return {module:client.Promise} Resolves: A list of the user's current rooms
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getJoinedRooms = function () {
+    const path = utils.encodeUri("/joined_rooms");
+    return this._http.authedRequest(undefined, "GET", path);
+};
+
+/**
+ * Retrieve membership info. for a room.
+ * @param {string} roomId ID of the room to get membership for
+ * @return {module:client.Promise} Resolves: A list of currently joined users
+ *                                 and their profile data.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getJoinedRoomMembers = function (roomId) {
+    const path = utils.encodeUri("/rooms/$roomId/joined_members", {
+        $roomId: roomId
+    });
+    return this._http.authedRequest(undefined, "GET", path);
+};
 
 // Room Directory operations
 // =========================
 
 /**
+ * @param {Object} options Options for this request
  * @param {string} options.server The remote server to query for the room list.
  *                                Optional. If unspecified, get the local home
  *                                server's public room list.
  * @param {number} options.limit Maximum number of entries to return
  * @param {string} options.since Token to paginate from
  * @param {object} options.filter Filter parameters
  * @param {string} options.filter.generic_search_term String to search for
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.publicRooms = function(options, callback) {
-    if (typeof(options) == 'function') {
+MatrixBaseApis.prototype.publicRooms = function (options, callback) {
+    if (typeof options == 'function') {
         callback = options;
         options = {};
     }
     if (options === undefined) {
         options = {};
     }
 
-    var query_params = {};
+    const query_params = {};
     if (options.server) {
         query_params.server = options.server;
         delete options.server;
     }
 
     if (Object.keys(options).length === 0 && Object.keys(query_params).length === 0) {
         return this._http.authedRequest(callback, "GET", "/publicRooms");
     } else {
-        return this._http.authedRequest(
-            callback, "POST", "/publicRooms", query_params, options
-        );
+        return this._http.authedRequest(callback, "POST", "/publicRooms", query_params, options);
     }
 };
 
 /**
  * Create an alias to room ID mapping.
  * @param {string} alias The room alias to create.
  * @param {string} roomId The room ID to link the alias to.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.createAlias = function(alias, roomId, callback) {
-    var path = utils.encodeUri("/directory/room/$alias", {
+MatrixBaseApis.prototype.createAlias = function (alias, roomId, callback) {
+    const path = utils.encodeUri("/directory/room/$alias", {
         $alias: alias
     });
-    var data = {
+    const data = {
         room_id: roomId
     };
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, data
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, data);
 };
 
 /**
  * Delete an alias to room ID mapping.  This alias must be on your local server
  * and you must have sufficient access to do this operation.
  * @param {string} alias The room alias to delete.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.deleteAlias = function(alias, callback) {
-    var path = utils.encodeUri("/directory/room/$alias", {
+MatrixBaseApis.prototype.deleteAlias = function (alias, callback) {
+    const path = utils.encodeUri("/directory/room/$alias", {
         $alias: alias
     });
-    return this._http.authedRequest(
-        callback, "DELETE", path, undefined, undefined
-    );
+    return this._http.authedRequest(callback, "DELETE", path, undefined, undefined);
 };
 
 /**
  * Get room info for the given alias.
  * @param {string} alias The room alias to resolve.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: Object with room_id and servers.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getRoomIdForAlias = function(alias, callback) {
+MatrixBaseApis.prototype.getRoomIdForAlias = function (alias, callback) {
     // TODO: deprecate this or resolveRoomAlias
-    var path = utils.encodeUri("/directory/room/$alias", {
+    const path = utils.encodeUri("/directory/room/$alias", {
         $alias: alias
     });
-    return this._http.authedRequest(
-        callback, "GET", path
-    );
+    return this._http.authedRequest(callback, "GET", path);
 };
 
 /**
  * @param {string} roomAlias
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.resolveRoomAlias = function(roomAlias, callback) {
+MatrixBaseApis.prototype.resolveRoomAlias = function (roomAlias, callback) {
     // TODO: deprecate this or getRoomIdForAlias
-    var path = utils.encodeUri("/directory/room/$alias", {$alias: roomAlias});
+    const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
     return this._http.request(callback, "GET", path);
 };
 
 /**
  * Get the visibility of a room in the current HS's room directory
  * @param {string} roomId
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getRoomDirectoryVisibility =
-                                function(roomId, callback) {
-    var path = utils.encodeUri("/directory/list/room/$roomId", {
+MatrixBaseApis.prototype.getRoomDirectoryVisibility = function (roomId, callback) {
+    const path = utils.encodeUri("/directory/list/room/$roomId", {
         $roomId: roomId
     });
     return this._http.authedRequest(callback, "GET", path);
 };
 
 /**
  * Set the visbility of a room in the current HS's room directory
  * @param {string} roomId
  * @param {string} visibility "public" to make the room visible
  *                 in the public directory, or "private" to make
  *                 it invisible.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.setRoomDirectoryVisibility =
-                                function(roomId, visibility, callback) {
-    var path = utils.encodeUri("/directory/list/room/$roomId", {
+MatrixBaseApis.prototype.setRoomDirectoryVisibility = function (roomId, visibility, callback) {
+    const path = utils.encodeUri("/directory/list/room/$roomId", {
+        $roomId: roomId
+    });
+    return this._http.authedRequest(callback, "PUT", path, undefined, { "visibility": visibility });
+};
+
+/**
+ * Set the visbility of a room bridged to a 3rd party network in
+ * the current HS's room directory.
+ * @param {string} networkId the network ID of the 3rd party
+ *                 instance under which this room is published under.
+ * @param {string} roomId
+ * @param {string} visibility "public" to make the room visible
+ *                 in the public directory, or "private" to make
+ *                 it invisible.
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: result object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.setRoomDirectoryVisibilityAppService = function (networkId, roomId, visibility, callback) {
+    const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", {
+        $networkId: networkId,
         $roomId: roomId
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, { "visibility": visibility }
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, { "visibility": visibility });
 };
 
+// User Directory Operations
+// =========================
+
+/**
+ * Query the user directory with a term matching user IDs, display names and domains.
+ * @param {object} opts options
+ * @param {string} opts.term the term with which to search.
+ * @param {number} opts.limit the maximum number of results to return. The server will
+ *                 apply a limit if unspecified.
+ * @return {module:client.Promise} Resolves: an array of results.
+ */
+MatrixBaseApis.prototype.searchUserDirectory = function (opts) {
+    const body = {
+        search_term: opts.term
+    };
+
+    if (opts.limit !== undefined) {
+        body.limit = opts.limit;
+    }
+
+    return this._http.authedRequest(undefined, "POST", "/user_directory/search", undefined, body);
+};
 
 // Media operations
 // ================
 
 /**
  * Upload a file to the media repository on the home server.
  *
  * @param {object} file The object to upload. On a browser, something that
  *   can be sent to XMLHttpRequest.send (typically a File).  Under node.js,
  *   a a Buffer, String or ReadStream.
  *
  * @param {object} opts  options object
  *
  * @param {string=} opts.name   Name to give the file on the server. Defaults
  *   to <tt>file.name</tt>.
  *
+ * @param {boolean=} opts.includeFilename if false will not send the filename,
+ *   e.g for encrypted file uploads where filename leaks are undesirable.
+ *   Defaults to true.
+ *
  * @param {string=} opts.type   Content-type for the upload. Defaults to
  *   <tt>file.type</tt>, or <tt>applicaton/octet-stream</tt>.
  *
  * @param {boolean=} opts.rawResponse Return the raw body, rather than
  *   parsing the JSON. Defaults to false (except on node.js, where it
  *   defaults to true for backwards compatibility).
  *
  * @param {boolean=} opts.onlyContentUri Just return the content URI,
  *   rather than the whole body. Defaults to false (except on browsers,
  *   where it defaults to true for backwards compatibility). Ignored if
  *   opts.rawResponse is true.
  *
  * @param {Function=} opts.callback Deprecated. Optional. The callback to
  *    invoke on success/failure. See the promise return values for more
  *    information.
  *
+ * @param {Function=} opts.progressHandler Optional. Called when a chunk of
+ *    data has been uploaded, with an object containing the fields `loaded`
+ *    (number of bytes transferred) and `total` (total size, if known).
+ *
  * @return {module:client.Promise} Resolves to response object, as
  *    determined by this.opts.onlyData, opts.rawResponse, and
  *    opts.onlyContentUri.  Rejects with an error (usually a MatrixError).
  */
-MatrixBaseApis.prototype.uploadContent = function(file, opts) {
+MatrixBaseApis.prototype.uploadContent = function (file, opts) {
     return this._http.uploadContent(file, opts);
 };
 
 /**
  * Cancel a file upload in progress
  * @param {module:client.Promise} promise The promise returned from uploadContent
  * @return {boolean} true if canceled, otherwise false
  */
-MatrixBaseApis.prototype.cancelUpload = function(promise) {
+MatrixBaseApis.prototype.cancelUpload = function (promise) {
     return this._http.cancelUpload(promise);
 };
 
 /**
  * Get a list of all file uploads in progress
  * @return {array} Array of objects representing current uploads.
  * Currently in progress is element 0. Keys:
  *  - promise: The promise associated with the upload
  *  - loaded: Number of bytes uploaded
  *  - total: Total number of bytes to upload
  */
-MatrixBaseApis.prototype.getCurrentUploads = function() {
+MatrixBaseApis.prototype.getCurrentUploads = function () {
     return this._http.getCurrentUploads();
 };
 
-
 // Profile operations
 // ==================
 
 /**
  * @param {string} userId
  * @param {string} info The kind of info to retrieve (e.g. 'displayname',
  * 'avatar_url').
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getProfileInfo = function(userId, info, callback) {
-    if (utils.isFunction(info)) { callback = info; info = undefined; }
+MatrixBaseApis.prototype.getProfileInfo = function (userId, info, callback) {
+    if (utils.isFunction(info)) {
+        callback = info;info = undefined;
+    }
 
-    var path = info ?
-    utils.encodeUri("/profile/$userId/$info",
-             { $userId: userId, $info: info }) :
-    utils.encodeUri("/profile/$userId",
-             { $userId: userId });
+    const path = info ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info }) : utils.encodeUri("/profile/$userId", { $userId: userId });
     return this._http.authedRequest(callback, "GET", path);
 };
 
-
 // Account operations
 // ==================
 
 /**
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getThreePids = function(callback) {
-    var path = "/account/3pid";
-    return this._http.authedRequest(
-        callback, "GET", path, undefined, undefined
-    );
+MatrixBaseApis.prototype.getThreePids = function (callback) {
+    const path = "/account/3pid";
+    return this._http.authedRequest(callback, "GET", path, undefined, undefined);
 };
 
 /**
+ * Add a 3PID to your homeserver account and optionally bind it to an identity
+ * server as well. An identity server is required as part of the `creds` object.
+ *
+ * This API is deprecated, and you should instead use `addThreePidOnly`
+ * for homeservers that support it.
+ *
  * @param {Object} creds
  * @param {boolean} bind
  * @param {module:client.callback} callback Optional.
- * @return {module:client.Promise} Resolves: TODO
+ * @return {module:client.Promise} Resolves: on success
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.addThreePid = function(creds, bind, callback) {
-    var path = "/account/3pid";
-    var data = {
+MatrixBaseApis.prototype.addThreePid = function (creds, bind, callback) {
+    const path = "/account/3pid";
+    const data = {
         'threePidCreds': creds,
         'bind': bind
     };
-    return this._http.authedRequest(
-        callback, "POST", path, null, data
-    );
+    return this._http.authedRequest(callback, "POST", path, null, data);
+};
+
+/**
+ * Add a 3PID to your homeserver account. This API does not use an identity
+ * server, as the homeserver is expected to handle 3PID ownership validation.
+ *
+ * You can check whether a homeserver supports this API via
+ * `doesServerSupportSeparateAddAndBind`.
+ *
+ * @param {Object} data A object with 3PID validation data from having called
+ * `account/3pid/<medium>/requestToken` on the homeserver.
+ * @return {module:client.Promise} Resolves: on success
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.addThreePidOnly = function (data) {
+    const path = "/account/3pid/add";
+    return this._http.authedRequest(undefined, "POST", path, null, data, {
+        prefix: httpApi.PREFIX_UNSTABLE
+    });
+};
+
+/**
+ * Bind a 3PID for discovery onto an identity server via the homeserver. The
+ * identity server handles 3PID ownership validation and the homeserver records
+ * the new binding to track where all 3PIDs for the account are bound.
+ *
+ * You can check whether a homeserver supports this API via
+ * `doesServerSupportSeparateAddAndBind`.
+ *
+ * @param {Object} data A object with 3PID validation data from having called
+ * `validate/<medium>/requestToken` on the identity server. It should also
+ * contain `id_server` and `id_access_token` fields as well.
+ * @return {module:client.Promise} Resolves: on success
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.bindThreePid = function (data) {
+    const path = "/account/3pid/bind";
+    return this._http.authedRequest(undefined, "POST", path, null, data, {
+        prefix: httpApi.PREFIX_UNSTABLE
+    });
+};
+
+/**
+ * Unbind a 3PID for discovery on an identity server via the homeserver. The
+ * homeserver removes its record of the binding to keep an updated record of
+ * where all 3PIDs for the account are bound.
+ *
+ * @param {string} medium The threepid medium (eg. 'email')
+ * @param {string} address The threepid address (eg. 'bob@example.com')
+ *        this must be as returned by getThreePids.
+ * @return {module:client.Promise} Resolves: on success
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.unbindThreePid = function (medium, address) {
+    const path = "/account/3pid/unbind";
+    const data = {
+        medium,
+        address,
+        id_server: this.getIdentityServerUrl(true)
+    };
+    return this._http.authedRequest(undefined, "POST", path, null, data, {
+        prefix: httpApi.PREFIX_UNSTABLE
+    });
+};
+
+/**
+ * @param {string} medium The threepid medium (eg. 'email')
+ * @param {string} address The threepid address (eg. 'bob@example.com')
+ *        this must be as returned by getThreePids.
+ * @return {module:client.Promise} Resolves: The server response on success
+ *     (generally the empty JSON object)
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.deleteThreePid = function (medium, address) {
+    const path = "/account/3pid/delete";
+    const data = {
+        'medium': medium,
+        'address': address
+    };
+    return this._http.authedRequest(undefined, "POST", path, null, data);
 };
 
 /**
  * Make a request to change your password.
  * @param {Object} authDict
  * @param {string} newPassword The new desired password.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.setPassword = function(authDict, newPassword, callback) {
-    var path = "/account/password";
-    var data = {
+MatrixBaseApis.prototype.setPassword = function (authDict, newPassword, callback) {
+    const path = "/account/password";
+    const data = {
         'auth': authDict,
         'new_password': newPassword
     };
 
-    return this._http.authedRequest(
-        callback, "POST", path, null, data
-    );
+    return this._http.authedRequest(callback, "POST", path, null, data);
 };
 
-
 // Device operations
 // =================
 
 /**
  * Gets all devices recorded for the logged-in user
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getDevices = function() {
-    var path = "/devices";
-    return this._http.authedRequestWithPrefix(
-        undefined, "GET", path, undefined, undefined,
-        httpApi.PREFIX_UNSTABLE
-    );
+MatrixBaseApis.prototype.getDevices = function () {
+    return this._http.authedRequest(undefined, 'GET', "/devices", undefined, undefined);
 };
 
 /**
  * Update the given device
  *
  * @param {string} device_id  device to update
  * @param {Object} body       body of request
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.setDeviceDetails = function(device_id, body) {
-    var path = utils.encodeUri("/devices/$device_id", {
-        $device_id: device_id,
+MatrixBaseApis.prototype.setDeviceDetails = function (device_id, body) {
+    const path = utils.encodeUri("/devices/$device_id", {
+        $device_id: device_id
     });
 
-
-    return this._http.authedRequestWithPrefix(
-        undefined, "PUT", path, undefined, body,
-        httpApi.PREFIX_UNSTABLE
-    );
+    return this._http.authedRequest(undefined, "PUT", path, undefined, body);
 };
 
 /**
  * Delete the given device
  *
  * @param {string} device_id  device to delete
  * @param {object} auth Optional. Auth data to supply for User-Interactive auth.
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.deleteDevice = function(device_id, auth) {
-    var path = utils.encodeUri("/devices/$device_id", {
-        $device_id: device_id,
+MatrixBaseApis.prototype.deleteDevice = function (device_id, auth) {
+    const path = utils.encodeUri("/devices/$device_id", {
+        $device_id: device_id
     });
 
-    var body = {};
+    const body = {};
 
     if (auth) {
         body.auth = auth;
     }
 
-    return this._http.authedRequestWithPrefix(
-        undefined, "DELETE", path, undefined, body,
-        httpApi.PREFIX_UNSTABLE
-    );
+    return this._http.authedRequest(undefined, "DELETE", path, undefined, body);
 };
 
+/**
+ * Delete multiple device
+ *
+ * @param {string[]} devices IDs of the devices to delete
+ * @param {object} auth Optional. Auth data to supply for User-Interactive auth.
+ * @return {module:client.Promise} Resolves: result object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.deleteMultipleDevices = function (devices, auth) {
+    const body = { devices };
+
+    if (auth) {
+        body.auth = auth;
+    }
+
+    const path = "/delete_devices";
+    return this._http.authedRequest(undefined, "POST", path, undefined, body);
+};
 
 // Push operations
 // ===============
 
 /**
  * Gets all pushers registered for the logged-in user
  *
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: Array of objects representing pushers
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getPushers = function(callback) {
-    var path = "/pushers";
-    return this._http.authedRequest(
-        callback, "GET", path, undefined, undefined
-    );
+MatrixBaseApis.prototype.getPushers = function (callback) {
+    const path = "/pushers";
+    return this._http.authedRequest(callback, "GET", path, undefined, undefined);
 };
 
 /**
  * Adds a new pusher or updates an existing pusher
  *
  * @param {Object} pusher Object representing a pusher
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: Empty json object on success
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.setPusher = function(pusher, callback) {
-    var path = "/pushers/set";
-    return this._http.authedRequest(
-        callback, "POST", path, null, pusher
-    );
+MatrixBaseApis.prototype.setPusher = function (pusher, callback) {
+    const path = "/pushers/set";
+    return this._http.authedRequest(callback, "POST", path, null, pusher);
 };
 
 /**
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.getPushRules = function(callback) {
-    return this._http.authedRequest(callback, "GET", "/pushrules/");
+MatrixBaseApis.prototype.getPushRules = function (callback) {
+    return this._http.authedRequest(callback, "GET", "/pushrules/").then(rules => {
+        return PushProcessor.rewriteDefaultRules(rules);
+    });
 };
 
 /**
  * @param {string} scope
  * @param {string} kind
  * @param {string} ruleId
  * @param {Object} body
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.addPushRule = function(scope, kind, ruleId, body, callback) {
+MatrixBaseApis.prototype.addPushRule = function (scope, kind, ruleId, body, callback) {
     // NB. Scope not uri encoded because devices need the '/'
-    var path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
+    const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
         $kind: kind,
         $ruleId: ruleId
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, body
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, body);
 };
 
 /**
  * @param {string} scope
  * @param {string} kind
  * @param {string} ruleId
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.deletePushRule = function(scope, kind, ruleId, callback) {
+MatrixBaseApis.prototype.deletePushRule = function (scope, kind, ruleId, callback) {
     // NB. Scope not uri encoded because devices need the '/'
-    var path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
+    const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
         $kind: kind,
         $ruleId: ruleId
     });
     return this._http.authedRequest(callback, "DELETE", path);
 };
 
 /**
  * Enable or disable a push notification rule.
  * @param {string} scope
  * @param {string} kind
  * @param {string} ruleId
  * @param {boolean} enabled
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.setPushRuleEnabled = function(scope, kind,
-                                                     ruleId, enabled, callback) {
-    var path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", {
+MatrixBaseApis.prototype.setPushRuleEnabled = function (scope, kind, ruleId, enabled, callback) {
+    const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", {
         $kind: kind,
         $ruleId: ruleId
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, {"enabled": enabled}
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, { "enabled": enabled });
 };
 
 /**
  * Set the actions for a push notification rule.
  * @param {string} scope
  * @param {string} kind
  * @param {string} ruleId
  * @param {array} actions
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.setPushRuleActions = function(scope, kind,
-                                                     ruleId, actions, callback) {
-    var path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", {
+MatrixBaseApis.prototype.setPushRuleActions = function (scope, kind, ruleId, actions, callback) {
+    const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", {
         $kind: kind,
         $ruleId: ruleId
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, {"actions": actions}
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, { "actions": actions });
 };
 
-
 // Search
 // ======
 
 /**
  * Perform a server-side search.
  * @param {Object} opts
  * @param {string} opts.next_batch the batch token to pass in the query string
  * @param {Object} opts.body the JSON object to pass to the request body.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.search = function(opts, callback) {
-    var queryparams = {};
+MatrixBaseApis.prototype.search = function (opts, callback) {
+    const queryparams = {};
     if (opts.next_batch) {
         queryparams.next_batch = opts.next_batch;
     }
-    return this._http.authedRequest(
-        callback, "POST", "/search", queryparams, opts.body
-    );
+    return this._http.authedRequest(callback, "POST", "/search", queryparams, opts.body);
 };
 
 // Crypto
 // ======
 
 /**
  * Upload keys
  *
@@ -927,208 +1547,620 @@ MatrixBaseApis.prototype.search = functi
  * @param {string=} opts.device_id  explicit device_id to use for upload
  *    (default is to use the same as that used during auth).
  *
  * @param {module:client.callback=} callback
  *
  * @return {module:client.Promise} Resolves: result object. Rejects: with
  *     an error response ({@link module:http-api.MatrixError}).
  */
-MatrixBaseApis.prototype.uploadKeysRequest = function(content, opts, callback) {
+MatrixBaseApis.prototype.uploadKeysRequest = function (content, opts, callback) {
     opts = opts || {};
-    var deviceId = opts.device_id;
-    var path;
+    const deviceId = opts.device_id;
+    let path;
     if (deviceId) {
         path = utils.encodeUri("/keys/upload/$deviceId", {
-            $deviceId: deviceId,
+            $deviceId: deviceId
         });
     } else {
         path = "/keys/upload";
     }
-    return this._http.authedRequestWithPrefix(
-        callback, "POST", path, undefined, content, httpApi.PREFIX_UNSTABLE
-    );
+    return this._http.authedRequest(callback, "POST", path, undefined, content);
 };
 
 /**
  * Download device keys
  *
  * @param {string[]} userIds  list of users to get keys for
  *
- * @param {module:client.callback=} callback
+ * @param {Object=} opts
+ *
+ * @param {string=} opts.token   sync token to pass in the query request, to help
+ *   the HS give the most recent results
  *
  * @return {module:client.Promise} Resolves: result object. Rejects: with
  *     an error response ({@link module:http-api.MatrixError}).
  */
-MatrixBaseApis.prototype.downloadKeysForUsers = function(userIds, callback) {
-    var downloadQuery = {};
-
-    for (var i = 0; i < userIds.length; ++i) {
-        downloadQuery[userIds[i]] = {};
+MatrixBaseApis.prototype.downloadKeysForUsers = function (userIds, opts) {
+    if (utils.isFunction(opts)) {
+        // opts used to be 'callback'.
+        throw new Error('downloadKeysForUsers no longer accepts a callback parameter');
     }
-    var content = {device_keys: downloadQuery};
-    return this._http.authedRequestWithPrefix(
-        callback, "POST", "/keys/query", undefined, content,
-        httpApi.PREFIX_UNSTABLE
-    );
+    opts = opts || {};
+
+    const content = {
+        device_keys: {}
+    };
+    if ('token' in opts) {
+        content.token = opts.token;
+    }
+    userIds.forEach(u => {
+        content.device_keys[u] = {};
+    });
+
+    return this._http.authedRequest(undefined, "POST", "/keys/query", undefined, content);
 };
 
 /**
  * Claim one-time keys
  *
- * @param {string[][]} devices  a list of [userId, deviceId] pairs
+ * @param {string[]} devices  a list of [userId, deviceId] pairs
  *
  * @param {string} [key_algorithm = signed_curve25519]  desired key type
  *
  * @return {module:client.Promise} Resolves: result object. Rejects: with
  *     an error response ({@link module:http-api.MatrixError}).
  */
-MatrixBaseApis.prototype.claimOneTimeKeys = function(devices, key_algorithm) {
-    var queries = {};
+MatrixBaseApis.prototype.claimOneTimeKeys = function (devices, key_algorithm) {
+    const queries = {};
 
     if (key_algorithm === undefined) {
         key_algorithm = "signed_curve25519";
     }
 
-    for (var i = 0; i < devices.length; ++i) {
-        var userId = devices[i][0];
-        var deviceId = devices[i][1];
-        var query = queries[userId] || {};
+    for (let i = 0; i < devices.length; ++i) {
+        const userId = devices[i][0];
+        const deviceId = devices[i][1];
+        const query = queries[userId] || {};
         queries[userId] = query;
         query[deviceId] = key_algorithm;
     }
-    var content = {one_time_keys: queries};
-    return this._http.authedRequestWithPrefix(
-        undefined, "POST", "/keys/claim", undefined, content,
-        httpApi.PREFIX_UNSTABLE
-    );
+    const content = { one_time_keys: queries };
+    const path = "/keys/claim";
+    return this._http.authedRequest(undefined, "POST", path, undefined, content);
 };
 
+/**
+ * Ask the server for a list of users who have changed their device lists
+ * between a pair of sync tokens
+ *
+ * @param {string} oldToken
+ * @param {string} newToken
+ *
+ * @return {module:client.Promise} Resolves: result object. Rejects: with
+ *     an error response ({@link module:http-api.MatrixError}).
+ */
+MatrixBaseApis.prototype.getKeyChanges = function (oldToken, newToken) {
+    const qps = {
+        from: oldToken,
+        to: newToken
+    };
+
+    const path = "/keys/changes";
+    return this._http.authedRequest(undefined, "GET", path, qps, undefined);
+};
 
 // Identity Server Operations
 // ==========================
 
 /**
- * Requests an email verification token directly from an Identity Server.
+ * Register with an Identity Server using the OpenID token from the user's
+ * Homeserver, which can be retrieved via
+ * {@link module:client~MatrixClient#getOpenIdToken}.
+ *
+ * Note that the `/account/register` endpoint (as well as IS authentication in
+ * general) was added as part of the v2 API version.
  *
- * Note that the Home Server offers APIs to proxy this API for specific
- * situations, allowing for better feedback to the user.
+ * @param {object} hsOpenIdToken
+ * @return {module:client.Promise} Resolves: with object containing an Identity
+ * Server access token.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.registerWithIdentityServer = function (hsOpenIdToken) {
+    if (!this.idBaseUrl) {
+        throw new Error("No Identity Server base URL set");
+    }
+
+    const uri = this.idBaseUrl + httpApi.PREFIX_IDENTITY_V2 + "/account/register";
+    return this._http.requestOtherUrl(undefined, "POST", uri, null, hsOpenIdToken);
+};
+
+/**
+ * Requests an email verification token directly from an identity server.
+ *
+ * This API is used as part of binding an email for discovery on an identity
+ * server. The validation data that results should be passed to the
+ * `bindThreePid` method to complete the binding process.
  *
  * @param {string} email The email address to request a token for
  * @param {string} clientSecret A secret binary string generated by the client.
  *                 It is recommended this be around 16 ASCII characters.
  * @param {number} sendAttempt If an identity server sees a duplicate request
  *                 with the same sendAttempt, it will not send another email.
  *                 To request another email to be sent, use a larger value for
  *                 the sendAttempt param as was used in the previous request.
  * @param {string} nextLink Optional If specified, the client will be redirected
  *                 to this link after validation.
  * @param {module:client.callback} callback Optional.
+ * @param {string} identityAccessToken The `access_token` field of the identity
+ * server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
- * @throws Error if No ID server is set
+ * @throws Error if no identity server is set
  */
-MatrixBaseApis.prototype.requestEmailToken = function(email, clientSecret,
-                                                    sendAttempt, nextLink, callback) {
-    var params = {
+MatrixBaseApis.prototype.requestEmailToken = async function (email, clientSecret, sendAttempt, nextLink, callback, identityAccessToken) {
+    const params = {
         client_secret: clientSecret,
         email: email,
         send_attempt: sendAttempt,
         next_link: nextLink
     };
-    return this._http.idServerRequest(
-        callback, "POST", "/validate/email/requestToken",
-        params, httpApi.PREFIX_IDENTITY_V1
-    );
+
+    try {
+        const response = await this._http.idServerRequest(undefined, "POST", "/validate/email/requestToken", params, httpApi.PREFIX_IDENTITY_V2, identityAccessToken);
+        // TODO: Fold callback into above call once v1 path below is removed
+        if (callback) callback(null, response);
+        return response;
+    } catch (err) {
+        if (err.cors === "rejected" || err.httpStatus === 404) {
+            // Fall back to deprecated v1 API for now
+            // TODO: Remove this path once v2 is only supported version
+            // See https://github.com/vector-im/riot-web/issues/10443
+            _logger2.default.warn("IS doesn't support v2, falling back to deprecated v1");
+            return await this._http.idServerRequest(callback, "POST", "/validate/email/requestToken", params, httpApi.PREFIX_IDENTITY_V1);
+        }
+        if (callback) callback(err);
+        throw err;
+    }
+};
+
+/**
+ * Requests a MSISDN verification token directly from an identity server.
+ *
+ * This API is used as part of binding a MSISDN for discovery on an identity
+ * server. The validation data that results should be passed to the
+ * `bindThreePid` method to complete the binding process.
+ *
+ * @param {string} phoneCountry The ISO 3166-1 alpha-2 code for the country in
+ *                 which phoneNumber should be parsed relative to.
+ * @param {string} phoneNumber The phone number, in national or international
+ *                 format
+ * @param {string} clientSecret A secret binary string generated by the client.
+ *                 It is recommended this be around 16 ASCII characters.
+ * @param {number} sendAttempt If an identity server sees a duplicate request
+ *                 with the same sendAttempt, it will not send another SMS.
+ *                 To request another SMS to be sent, use a larger value for
+ *                 the sendAttempt param as was used in the previous request.
+ * @param {string} nextLink Optional If specified, the client will be redirected
+ *                 to this link after validation.
+ * @param {module:client.callback} callback Optional.
+ * @param {string} identityAccessToken The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ * @throws Error if no identity server is set
+ */
+MatrixBaseApis.prototype.requestMsisdnToken = async function (phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink, callback, identityAccessToken) {
+    const params = {
+        client_secret: clientSecret,
+        country: phoneCountry,
+        phone_number: phoneNumber,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    };
+
+    try {
+        const response = await this._http.idServerRequest(undefined, "POST", "/validate/msisdn/requestToken", params, httpApi.PREFIX_IDENTITY_V2, identityAccessToken);
+        // TODO: Fold callback into above call once v1 path below is removed
+        if (callback) callback(null, response);
+        return response;
+    } catch (err) {
+        if (err.cors === "rejected" || err.httpStatus === 404) {
+            // Fall back to deprecated v1 API for now
+            // TODO: Remove this path once v2 is only supported version
+            // See https://github.com/vector-im/riot-web/issues/10443
+            _logger2.default.warn("IS doesn't support v2, falling back to deprecated v1");
+            return await this._http.idServerRequest(callback, "POST", "/validate/msisdn/requestToken", params, httpApi.PREFIX_IDENTITY_V1);
+        }
+        if (callback) callback(err);
+        throw err;
+    }
+};
+
+/**
+ * Submits a MSISDN token to the identity server
+ *
+ * This is used when submitting the code sent by SMS to a phone number.
+ * The ID server has an equivalent API for email but the js-sdk does
+ * not expose this, since email is normally validated by the user clicking
+ * a link rather than entering a code.
+ *
+ * @param {string} sid The sid given in the response to requestToken
+ * @param {string} clientSecret A secret binary string generated by the client.
+ *                 This must be the same value submitted in the requestToken call.
+ * @param {string} msisdnToken The MSISDN token, as enetered by the user.
+ * @param {string} identityAccessToken The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @return {module:client.Promise} Resolves: Object, currently with no parameters.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ * @throws Error if No ID server is set
+ */
+MatrixBaseApis.prototype.submitMsisdnToken = async function (sid, clientSecret, msisdnToken, identityAccessToken) {
+    const params = {
+        sid: sid,
+        client_secret: clientSecret,
+        token: msisdnToken
+    };
+
+    try {
+        return await this._http.idServerRequest(undefined, "POST", "/validate/msisdn/submitToken", params, httpApi.PREFIX_IDENTITY_V2, identityAccessToken);
+    } catch (err) {
+        if (err.cors === "rejected" || err.httpStatus === 404) {
+            // Fall back to deprecated v1 API for now
+            // TODO: Remove this path once v2 is only supported version
+            // See https://github.com/vector-im/riot-web/issues/10443
+            _logger2.default.warn("IS doesn't support v2, falling back to deprecated v1");
+            return await this._http.idServerRequest(undefined, "POST", "/validate/msisdn/submitToken", params, httpApi.PREFIX_IDENTITY_V1);
+        }
+        throw err;
+    }
+};
+
+/**
+ * Submits a MSISDN token to an arbitrary URL.
+ *
+ * This is used when submitting the code sent by SMS to a phone number in the
+ * newer 3PID flow where the homeserver validates 3PID ownership (as part of
+ * `requestAdd3pidMsisdnToken`). The homeserver response may include a
+ * `submit_url` to specify where the token should be sent, and this helper can
+ * be used to pass the token to this URL.
+ *
+ * @param {string} url The URL to submit the token to
+ * @param {string} sid The sid given in the response to requestToken
+ * @param {string} clientSecret A secret binary string generated by the client.
+ *                 This must be the same value submitted in the requestToken call.
+ * @param {string} msisdnToken The MSISDN token, as enetered by the user.
+ *
+ * @return {module:client.Promise} Resolves: Object, currently with no parameters.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.submitMsisdnTokenOtherUrl = function (url, sid, clientSecret, msisdnToken) {
+    const params = {
+        sid: sid,
+        client_secret: clientSecret,
+        token: msisdnToken
+    };
+
+    return this._http.requestOtherUrl(undefined, "POST", url, undefined, params);
+};
+
+/**
+ * Gets the V2 hashing information from the identity server. Primarily useful for
+ * lookups.
+ * @param {string} identityAccessToken The access token for the identity server.
+ * @returns {Promise<object>} The hashing information for the identity server.
+ */
+MatrixBaseApis.prototype.getIdentityHashDetails = function (identityAccessToken) {
+    return this._http.idServerRequest(undefined, "GET", "/hash_details", null, httpApi.PREFIX_IDENTITY_V2, identityAccessToken);
+};
+
+/**
+ * Performs a hashed lookup of addresses against the identity server. This is
+ * only supported on identity servers which have at least the version 2 API.
+ * @param {Array<Array<string,string>>} addressPairs An array of 2 element arrays.
+ * The first element of each pair is the address, the second is the 3PID medium.
+ * Eg: ["email@example.org", "email"]
+ * @param {string} identityAccessToken The access token for the identity server.
+ * @returns {Promise<Array<{address, mxid}>>} A collection of address mappings to
+ * found MXIDs. Results where no user could be found will not be listed.
+ */
+MatrixBaseApis.prototype.identityHashedLookup = async function (addressPairs, // [["email@example.org", "email"], ["10005550000", "msisdn"]]
+identityAccessToken) {
+    const params = {
+        // addresses: ["email@example.org", "10005550000"],
+        // algorithm: "sha256",
+        // pepper: "abc123"
+    };
+
+    // Get hash information first before trying to do a lookup
+    const hashes = await this.getIdentityHashDetails(identityAccessToken);
+    if (!hashes || !hashes['lookup_pepper'] || !hashes['algorithms']) {
+        throw new Error("Unsupported identity server: bad response");
+    }
+
+    params['pepper'] = hashes['lookup_pepper'];
+
+    const localMapping = {
+        // hashed identifier => plain text address
+        // For use in this function's return format
+    };
+
+    // When picking an algorithm, we pick the hashed over no hashes
+    if (hashes['algorithms'].includes('sha256')) {
+        // Abuse the olm hashing
+        const olmutil = new global.Olm.Utility();
+        params["addresses"] = addressPairs.map(p => {
+            const addr = p[0].toLowerCase(); // lowercase to get consistent hashes
+            const med = p[1].toLowerCase();
+            const hashed = olmutil.sha256(`${addr} ${med} ${params['pepper']}`).replace(/\+/g, '-').replace(/\//g, '_'); // URL-safe base64
+            // Map the hash to a known (case-sensitive) address. We use the case
+            // sensitive version because the caller might be expecting that.
+            localMapping[hashed] = p[0];
+            return hashed;
+        });
+        params["algorithm"] = "sha256";
+    } else if (hashes['algorithms'].includes('none')) {
+        params["addresses"] = addressPairs.map(p => {
+            const addr = p[0].toLowerCase(); // lowercase to get consistent hashes
+            const med = p[1].toLowerCase();
+            const unhashed = `${addr} ${med}`;
+            // Map the unhashed values to a known (case-sensitive) address. We use
+            // the case sensitive version because the caller might be expecting that.
+            localMapping[unhashed] = p[0];
+            return unhashed;
+        });
+        params["algorithm"] = "none";
+    } else {
+        throw new Error("Unsupported identity server: unknown hash algorithm");
+    }
+
+    const response = await this._http.idServerRequest(undefined, "POST", "/lookup", params, httpApi.PREFIX_IDENTITY_V2, identityAccessToken);
+
+    if (!response || !response['mappings']) return []; // no results
+
+    const foundAddresses = [/* {address: "plain@example.org", mxid} */];
+    for (const hashed of Object.keys(response['mappings'])) {
+        const mxid = response['mappings'][hashed];
+        const plainAddress = localMapping[hashed];
+        if (!plainAddress) {
+            throw new Error("Identity server returned more results than expected");
+        }
+
+        foundAddresses.push({ address: plainAddress, mxid });
+    }
+    return foundAddresses;
 };
 
 /**
  * Looks up the public Matrix ID mapping for a given 3rd party
  * identifier from the Identity Server
+ *
  * @param {string} medium The medium of the threepid, eg. 'email'
  * @param {string} address The textual address of the threepid
  * @param {module:client.callback} callback Optional.
+ * @param {string} identityAccessToken The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
  * @return {module:client.Promise} Resolves: A threepid mapping
  *                                 object or the empty object if no mapping
  *                                 exists
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixBaseApis.prototype.lookupThreePid = function(medium, address, callback) {
-    var params = {
-        medium: medium,
-        address: address,
-    };
-    return this._http.idServerRequest(
-        callback, "GET", "/lookup",
-        params, httpApi.PREFIX_IDENTITY_V1
-    );
+MatrixBaseApis.prototype.lookupThreePid = async function (medium, address, callback, identityAccessToken) {
+    try {
+        // Note: we're using the V2 API by calling this function, but our
+        // function contract requires a V1 response. We therefore have to
+        // convert it manually.
+        const response = await this.identityHashedLookup([[address, medium]], identityAccessToken);
+        const result = response.find(p => p.address === address);
+        if (!result) {
+            // TODO: Fold callback into above call once v1 path below is removed
+            if (callback) callback(null, {});
+            return {};
+        }
+
+        const mapping = {
+            address,
+            medium,
+            mxid: result.mxid
+
+            // We can't reasonably fill these parameters:
+            // not_before
+            // not_after
+            // ts
+            // signatures
+        };
+
+        // TODO: Fold callback into above call once v1 path below is removed
+        if (callback) callback(null, mapping);
+        return mapping;
+    } catch (err) {
+        if (err.cors === "rejected" || err.httpStatus === 404) {
+            // Fall back to deprecated v1 API for now
+            // TODO: Remove this path once v2 is only supported version
+            // See https://github.com/vector-im/riot-web/issues/10443
+            const params = {
+                medium: medium,
+                address: address
+            };
+            _logger2.default.warn("IS doesn't support v2, falling back to deprecated v1");
+            return await this._http.idServerRequest(callback, "GET", "/lookup", params, httpApi.PREFIX_IDENTITY_V1);
+        }
+        if (callback) callback(err, undefined);
+        throw err;
+    }
 };
 
+/**
+ * Looks up the public Matrix ID mappings for multiple 3PIDs.
+ *
+ * @param {Array.<Array.<string>>} query Array of arrays containing
+ * [medium, address]
+ * @param {string} identityAccessToken The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @return {module:client.Promise} Resolves: Lookup results from IS.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.bulkLookupThreePids = async function (query, identityAccessToken) {
+    try {
+        // Note: we're using the V2 API by calling this function, but our
+        // function contract requires a V1 response. We therefore have to
+        // convert it manually.
+        const response = await this.identityHashedLookup(
+        // We have to reverse the query order to get [address, medium] pairs
+        query.map(p => [p[1], p[0]]), identityAccessToken);
+
+        const v1results = [];
+        for (const mapping of response) {
+            const originalQuery = query.find(p => p[1] === mapping.address);
+            if (!originalQuery) {
+                throw new Error("Identity sever returned unexpected results");
+            }
+
+            v1results.push([originalQuery[0], // medium
+            mapping.address, mapping.mxid]);
+        }
+
+        return { threepids: v1results };
+    } catch (err) {
+        if (err.cors === "rejected" || err.httpStatus === 404) {
+            // Fall back to deprecated v1 API for now
+            // TODO: Remove this path once v2 is only supported version
+            // See https://github.com/vector-im/riot-web/issues/10443
+            const params = {
+                threepids: query
+            };
+            _logger2.default.warn("IS doesn't support v2, falling back to deprecated v1");
+            return await this._http.idServerRequest(undefined, "POST", "/bulk_lookup", params, httpApi.PREFIX_IDENTITY_V1, identityAccessToken);
+        }
+        throw err;
+    }
+};
+
+/**
+ * Get account info from the Identity Server. This is useful as a neutral check
+ * to verify that other APIs are likely to approve access by testing that the
+ * token is valid, terms have been agreed, etc.
+ *
+ * @param {string} identityAccessToken The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @return {module:client.Promise} Resolves: an object with account info.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.getIdentityAccount = function (identityAccessToken) {
+    return this._http.idServerRequest(undefined, "GET", "/account", undefined, httpApi.PREFIX_IDENTITY_V2, identityAccessToken);
+};
 
 // Direct-to-device messaging
 // ==========================
 
 /**
  * Send an event to a specific list of devices
  *
  * @param {string} eventType  type of event to send
  * @param {Object.<string, Object<string, Object>>} contentMap
  *    content to send. Map from user_id to device_id to content object.
  * @param {string=} txnId     transaction id. One will be made up if not
  *    supplied.
  * @return {module:client.Promise} Resolves to the result object
  */
-MatrixBaseApis.prototype.sendToDevice = function(
-    eventType, contentMap, txnId
-) {
-    var path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
+MatrixBaseApis.prototype.sendToDevice = function (eventType, contentMap, txnId) {
+    const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
         $eventType: eventType,
-        $txnId: txnId ? txnId : this.makeTxnId(),
+        $txnId: txnId ? txnId : this.makeTxnId()
     });
 
-    var body = {
-        messages: contentMap,
+    const body = {
+        messages: contentMap
     };
 
-    return this._http.authedRequestWithPrefix(
-        undefined, "PUT", path, undefined, body,
-        httpApi.PREFIX_UNSTABLE
-    );
+    return this._http.authedRequest(undefined, "PUT", path, undefined, body);
 };
 
 // Third party Lookup API
 // ======================
 
 /**
  * Get the third party protocols that can be reached using
  * this HS
  * @return {module:client.Promise} Resolves to the result object
  */
-MatrixBaseApis.prototype.getThirdpartyProtocols = function() {
-    return this._http.authedRequestWithPrefix(
-        undefined, "GET", "/thirdparty/protocols", undefined, undefined,
-        httpApi.PREFIX_UNSTABLE
-    );
+MatrixBaseApis.prototype.getThirdpartyProtocols = function () {
+    return this._http.authedRequest(undefined, "GET", "/thirdparty/protocols", undefined, undefined).then(response => {
+        // sanity check
+        if (!response || typeof response !== 'object') {
+            throw new Error(`/thirdparty/protocols did not return an object: ${response}`);
+        }
+        return response;
+    });
 };
 
 /**
  * Get information on how a specific place on a third party protocol
  * may be reached.
  * @param {string} protocol The protocol given in getThirdpartyProtocols()
- * @param {object} params Protocol-specific parameters, as given in th
+ * @param {object} params Protocol-specific parameters, as given in the
+ *                        response to getThirdpartyProtocols()
+ * @return {module:client.Promise} Resolves to the result object
+ */
+MatrixBaseApis.prototype.getThirdpartyLocation = function (protocol, params) {
+    const path = utils.encodeUri("/thirdparty/location/$protocol", {
+        $protocol: protocol
+    });
+
+    return this._http.authedRequest(undefined, "GET", path, params, undefined);
+};
+
+/**
+ * Get information on how a specific user on a third party protocol
+ * may be reached.
+ * @param {string} protocol The protocol given in getThirdpartyProtocols()
+ * @param {object} params Protocol-specific parameters, as given in the
  *                        response to getThirdpartyProtocols()
  * @return {module:client.Promise} Resolves to the result object
  */
-MatrixBaseApis.prototype.getThirdpartyLocation = function(protocol, params) {
-    var path = utils.encodeUri("/thirdparty/location/$protocol", {
+MatrixBaseApis.prototype.getThirdpartyUser = function (protocol, params) {
+    const path = utils.encodeUri("/thirdparty/user/$protocol", {
         $protocol: protocol
     });
 
-    return this._http.authedRequestWithPrefix(
-        undefined, "GET", path, params, undefined,
-        httpApi.PREFIX_UNSTABLE
-    );
+    return this._http.authedRequest(undefined, "GET", path, params, undefined);
+};
+
+MatrixBaseApis.prototype.getTerms = function (serviceType, baseUrl) {
+    const url = termsUrlForService(serviceType, baseUrl);
+    return this._http.requestOtherUrl(undefined, 'GET', url);
+};
+
+MatrixBaseApis.prototype.agreeToTerms = function (serviceType, baseUrl, accessToken, termsUrls) {
+    const url = termsUrlForService(serviceType, baseUrl);
+    const headers = {
+        Authorization: "Bearer " + accessToken
+    };
+    return this._http.requestOtherUrl(undefined, 'POST', url, null, { user_accepts: termsUrls }, { headers });
+};
+
+/**
+ * Reports an event as inappropriate to the server, which may then notify the appropriate people.
+ * @param {string} roomId The room in which the event being reported is located.
+ * @param {string} eventId The event to report.
+ * @param {number} score The score to rate this content as where -100 is most offensive and 0 is inoffensive.
+ * @param {string} reason The reason the content is being reported. May be blank.
+ * @returns {module:client.Promise} Resolves to an empty object if successful
+ */
+MatrixBaseApis.prototype.reportEvent = function (roomId, eventId, score, reason) {
+    const path = utils.encodeUri("/rooms/$roomId/report/$eventId", {
+        $roomId: roomId,
+        $eventId: eventId
+    });
+
+    return this._http.authedRequest(undefined, "POST", path, null, { score, reason });
 };
 
 /**
  * MatrixBaseApis object
  */
-module.exports = MatrixBaseApis;
+module.exports = MatrixBaseApis;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/client.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/client.js
@@ -1,58 +1,107 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2018-2019 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 
-var PushProcessor = require('./pushprocessor');
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _ReEmitter = require("./ReEmitter");
+
+var _ReEmitter2 = _interopRequireDefault(_ReEmitter);
+
+var _RoomList = require("./crypto/RoomList");
+
+var _RoomList2 = _interopRequireDefault(_RoomList);
+
+var _logger = require("../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _crypto = require("./crypto");
+
+var _crypto2 = _interopRequireDefault(_crypto);
+
+var _recoverykey = require("./crypto/recoverykey");
+
+var _backup_password = require("./crypto/backup_password");
+
+var _randomstring = require("./randomstring");
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const PushProcessor = require('./pushprocessor');
 
 /**
  * This is an internal module. See {@link MatrixClient} for the public class.
  * @module client
  */
-var EventEmitter = require("events").EventEmitter;
-var q = require("q");
-var url = require('url');
-
-var httpApi = require("./http-api");
-var MatrixEvent = require("./models/event").MatrixEvent;
-var EventStatus = require("./models/event").EventStatus;
-var EventTimeline = require("./models/event-timeline");
-var SearchResult = require("./models/search-result");
-var StubStore = require("./store/stub");
-var webRtcCall = require("./webrtc/call");
-var utils = require("./utils");
-var contentRepo = require("./content-repo");
-var Filter = require("./filter");
-var SyncApi = require("./sync");
-var MatrixBaseApis = require("./base-apis");
-var MatrixError = httpApi.MatrixError;
-
-var SCROLLBACK_DELAY_MS = 3000;
-var CRYPTO_ENABLED = false;
-
-try {
-    var Crypto = require("./crypto");
-    CRYPTO_ENABLED = true;
-} catch (e) {
-    console.error("olm load error", e);
-    // Olm not installed.
+const EventEmitter = require("events").EventEmitter;
+
+const url = require('url');
+
+const httpApi = require("./http-api");
+const MatrixEvent = require("./models/event").MatrixEvent;
+const EventStatus = require("./models/event").EventStatus;
+const EventTimeline = require("./models/event-timeline");
+const SearchResult = require("./models/search-result");
+const StubStore = require("./store/stub");
+const webRtcCall = require("./webrtc/call");
+const utils = require("./utils");
+const contentRepo = require("./content-repo");
+const Filter = require("./filter");
+const SyncApi = require("./sync");
+const MatrixBaseApis = require("./base-apis");
+const MatrixError = httpApi.MatrixError;
+const ContentHelpers = require("./content-helpers");
+const olmlib = require("./crypto/olmlib");
+
+// Disable warnings for now: we use deprecated bluebird functions
+// and need to migrate, but they spam the console with warnings.
+_bluebird2.default.config({ warnings: false });
+
+const SCROLLBACK_DELAY_MS = 3000;
+const CRYPTO_ENABLED = (0, _crypto.isCryptoAvailable)();
+const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
+
+function keysFromRecoverySession(sessions, decryptionKey, roomId) {
+    const keys = [];
+    for (const [sessionId, sessionData] of Object.entries(sessions)) {
+        try {
+            const decrypted = keyFromRecoverySession(sessionData, decryptionKey);
+            decrypted.session_id = sessionId;
+            decrypted.room_id = roomId;
+            keys.push(decrypted);
+        } catch (e) {
+            _logger2.default.log("Failed to decrypt session from backup");
+        }
+    }
+    return keys;
+}
+
+function keyFromRecoverySession(session, decryptionKey) {
+    return JSON.parse(decryptionKey.decrypt(session.session_data.ephemeral, session.session_data.mac, session.session_data.ciphertext));
 }
 
 /**
  * Construct a Matrix Client. Only directly construct this if you want to use
  * custom modules. Normally, {@link createClient} should be used
  * as it specifies 'sensible' defaults for these modules.
  * @constructor
  * @extends {external:EventEmitter}
@@ -67,798 +116,1668 @@ try {
  * requests. The value of this property is typically <code>require("request")
  * </code> as it returns a function which meets the required interface. See
  * {@link requestFunction} for more information.
  *
  * @param {string} opts.accessToken The access_token for this user.
  *
  * @param {string} opts.userId The user ID for this user.
  *
- * @param {Object=} opts.store The data store to use. If not specified,
- * this client will not store any HTTP responses.
+ * @param {IdentityServerProvider} [opts.identityServer]
+ * Optional. A provider object with one function `getAccessToken`, which is a
+ * callback that returns a Promise<String> of an identity access token to supply
+ * with identity requests. If the object is unset, no access token will be
+ * supplied.
+ * See also https://github.com/vector-im/riot-web/issues/10615 which seeks to
+ * replace the previous approach of manual access tokens params with this
+ * callback throughout the SDK.
+ *
+ * @param {Object=} opts.store
+ *    The data store used for sync data from the homeserver. If not specified,
+ *    this client will not store any HTTP responses. The `createClient` helper
+ *    will create a default store if needed.
+ *
+ * @param {module:store/session/webstorage~WebStorageSessionStore} opts.sessionStore
+ *    A store to be used for end-to-end crypto session data. Most data has been
+ *    migrated out of here to `cryptoStore` instead. If not specified,
+ *    end-to-end crypto will be disabled. The `createClient` helper
+ *    _will not_ create this store at the moment.
+ *
+ * @param {module:crypto.store.base~CryptoStore} opts.cryptoStore
+ *    A store to be used for end-to-end crypto session data. If not specified,
+ *    end-to-end crypto will be disabled. The `createClient` helper will create
+ *    a default store if needed.
  *
  * @param {string=} opts.deviceId A unique identifier for this device; used for
  *    tracking things like crypto keys and access tokens.  If not specified,
  *    end-to-end crypto will be disabled.
  *
- * @param {Object=} opts.sessionStore A store to be used for end-to-end crypto
- *    session data. This should be a {@link
- *    module:store/session/webstorage~WebStorageSessionStore|WebStorageSessionStore},
- *    or an object implementing the same interface. If not specified,
- *    end-to-end crypto will be disabled.
- *
  * @param {Object} opts.scheduler Optional. The scheduler to use. If not
  * specified, this client will not retry requests on failure. This client
  * will supply its own processing function to
  * {@link module:scheduler~MatrixScheduler#setProcessFunction}.
  *
  * @param {Object} opts.queryParams Optional. Extra query parameters to append
  * to all requests with this client. Useful for application services which require
  * <code>?user_id=</code>.
  *
+ * @param {Number=} opts.localTimeoutMs Optional. The default maximum amount of
+ * time to wait before timing out HTTP requests. If not specified, there is no timeout.
+ *
+ * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use
+ * Authorization header instead of query param to send the access token to the server.
+ *
  * @param {boolean} [opts.timelineSupport = false] Set to true to enable
  * improved timeline support ({@link
  * module:client~MatrixClient#getEventTimeline getEventTimeline}). It is
  * disabled by default for compatibility with older clients - in particular to
  * maintain support for back-paginating the live timeline after a '/sync'
  * result with a gap.
+ *
+ * @param {boolean} [opts.unstableClientRelationAggregation = false]
+ * Optional. Set to true to enable client-side aggregation of event relations
+ * via `EventTimelineSet#getRelationsForEvent`.
+ * This feature is currently unstable and the API may change without notice.
+ *
+ * @param {Array} [opts.verificationMethods] Optional. The verification method
+ * that the application can handle.  Each element should be an item from {@link
+ * module:crypto~verificationMethods verificationMethods}, or a class that
+ * implements the {$link module:crypto/verification/Base verifier interface}.
+ *
+ * @param {boolean} [opts.forceTURN]
+ * Optional. Whether relaying calls through a TURN server should be forced.
+ *
+ * @param {boolean} [opts.fallbackICEServerAllowed]
+ * Optional. Whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to false.
  */
 function MatrixClient(opts) {
+    opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl);
+    opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl);
+
     MatrixBaseApis.call(this, opts);
 
+    this.olmVersion = null; // Populated after initCrypto is done
+
+    this.reEmitter = new _ReEmitter2.default(this);
+
     this.store = opts.store || new StubStore();
 
     this.deviceId = opts.deviceId || null;
 
-    var userId = (opts.userId || null);
+    const userId = opts.userId || null;
     this.credentials = {
-        userId: userId,
+        userId: userId
     };
 
     this.scheduler = opts.scheduler;
     if (this.scheduler) {
-        var self = this;
-        this.scheduler.setProcessFunction(function(eventToSend) {
-            var room = self.getRoom(eventToSend.getRoomId());
+        const self = this;
+        this.scheduler.setProcessFunction(function (eventToSend) {
+            const room = self.getRoom(eventToSend.getRoomId());
             if (eventToSend.status !== EventStatus.SENDING) {
-                _updatePendingEventStatus(room, eventToSend,
-                                          EventStatus.SENDING);
+                _updatePendingEventStatus(room, eventToSend, EventStatus.SENDING);
             }
             return _sendEventHttpRequest(self, eventToSend);
         });
     }
     this.clientRunning = false;
 
     this.callList = {
         // callId: MatrixCall
     };
 
     // try constructing a MatrixCall to see if we are running in an environment
     // which has WebRTC. If we are, listen for and handle m.call.* events.
-    var call = webRtcCall.createNewMatrixCall(this);
+    const call = webRtcCall.createNewMatrixCall(this);
     this._supportsVoip = false;
     if (call) {
         setupCallEventHandler(this);
         this._supportsVoip = true;
     }
     this._syncingRetry = null;
     this._syncApi = null;
     this._peekSync = null;
     this._isGuest = false;
     this._ongoingScrollbacks = {};
     this.timelineSupport = Boolean(opts.timelineSupport);
     this.urlPreviewCache = {};
     this._notifTimelineSet = null;
+    this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
 
     this._crypto = null;
-    if (CRYPTO_ENABLED && opts.sessionStore !== null &&
-            userId !== null && this.deviceId !== null) {
-        this._crypto = new Crypto(
-            this, this,
-            opts.sessionStore,
-            userId, this.deviceId
-        );
-
-        this.olmVersion = Crypto.getOlmVersion();
-    }
+    this._cryptoStore = opts.cryptoStore;
+    this._sessionStore = opts.sessionStore;
+    this._verificationMethods = opts.verificationMethods;
+
+    this._forceTURN = opts.forceTURN || false;
+    this._fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false;
+
+    // List of which rooms have encryption enabled: separate from crypto because
+    // we still want to know which rooms are encrypted even if crypto is disabled:
+    // we don't want to start sending unencrypted events to them.
+    this._roomList = new _RoomList2.default(this._cryptoStore);
+
+    // The pushprocessor caches useful things, so keep one and re-use it
+    this._pushProcessor = new PushProcessor(this);
+
+    // Cache of the server's /versions response
+    // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
+    this._serverVersionsCache = null;
+
+    this._cachedCapabilities = null; // { capabilities: {}, lastUpdated: timestamp }
+
+    // The SDK doesn't really provide a clean way for events to recalculate the push
+    // actions for themselves, so we have to kinda help them out when they are encrypted.
+    // We do this so that push rules are correctly executed on events in their decrypted
+    // state, such as highlights when the user's name is mentioned.
+    this.on("Event.decrypted", event => {
+        const oldActions = event.getPushActions();
+        const actions = this._pushProcessor.actionsForEvent(event);
+        event.setPushActions(actions); // Might as well while we're here
+
+        const room = this.getRoom(event.getRoomId());
+        if (!room) return;
+
+        const currentCount = room.getUnreadNotificationCount("highlight");
+
+        // Ensure the unread counts are kept up to date if the event is encrypted
+        // We also want to make sure that the notification count goes up if we already
+        // have encrypted events to avoid other code from resetting 'highlight' to zero.
+        const oldHighlight = oldActions && oldActions.tweaks ? !!oldActions.tweaks.highlight : false;
+        const newHighlight = actions && actions.tweaks ? !!actions.tweaks.highlight : false;
+        if (oldHighlight !== newHighlight || currentCount > 0) {
+            // TODO: Handle mentions received while the client is offline
+            // See also https://github.com/vector-im/riot-web/issues/9069
+            if (!room.hasUserReadEvent(this.getUserId(), event.getId())) {
+                let newCount = currentCount;
+                if (newHighlight && !oldHighlight) newCount++;
+                if (!newHighlight && oldHighlight) newCount--;
+                room.setUnreadNotificationCount("highlight", newCount);
+
+                // Fix 'Mentions Only' rooms from not having the right badge count
+                const totalCount = room.getUnreadNotificationCount('total');
+                if (totalCount < newCount) {
+                    room.setUnreadNotificationCount('total', newCount);
+                }
+            }
+        }
+    });
+
+    // Like above, we have to listen for read receipts from ourselves in order to
+    // correctly handle notification counts on encrypted rooms.
+    // This fixes https://github.com/vector-im/riot-web/issues/9421
+    this.on("Room.receipt", (event, room) => {
+        if (room && this.isRoomEncrypted(room.roomId)) {
+            // Figure out if we've read something or if it's just informational
+            const content = event.getContent();
+            const isSelf = Object.keys(content).filter(eid => {
+                return Object.keys(content[eid]['m.read']).includes(this.getUserId());
+            }).length > 0;
+
+            if (!isSelf) return;
+
+            // Work backwards to determine how many events are unread. We also set
+            // a limit for how back we'll look to avoid spinning CPU for too long.
+            // If we hit the limit, we assume the count is unchanged.
+            const maxHistory = 20;
+            const events = room.getLiveTimeline().getEvents();
+
+            let highlightCount = 0;
+
+            for (let i = events.length - 1; i >= 0; i--) {
+                if (i === events.length - maxHistory) return; // limit reached
+
+                const event = events[i];
+
+                if (room.hasUserReadEvent(this.getUserId(), event.getId())) {
+                    // If the user has read the event, then the counting is done.
+                    break;
+                }
+
+                highlightCount += this.getPushActionsForEvent(event).tweaks.highlight ? 1 : 0;
+            }
+
+            // Note: we don't need to handle 'total' notifications because the counts
+            // will come from the server.
+            room.setUnreadNotificationCount("highlight", highlightCount);
+        }
+    });
 }
 utils.inherits(MatrixClient, EventEmitter);
 utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype);
 
 /**
+ * Clear any data out of the persistent stores used by the client.
+ *
+ * @returns {Promise} Promise which resolves when the stores have been cleared.
+ */
+MatrixClient.prototype.clearStores = function () {
+    if (this._clientRunning) {
+        throw new Error("Cannot clear stores while client is running");
+    }
+
+    const promises = [];
+
+    promises.push(this.store.deleteAllData());
+    if (this._cryptoStore) {
+        promises.push(this._cryptoStore.deleteAllData());
+    }
+    return _bluebird2.default.all(promises);
+};
+
+/**
+ * Get the user-id of the logged-in user
+ *
+ * @return {?string} MXID for the logged-in user, or null if not logged in
+ */
+MatrixClient.prototype.getUserId = function () {
+    if (this.credentials && this.credentials.userId) {
+        return this.credentials.userId;
+    }
+    return null;
+};
+
+/**
  * Get the domain for this client's MXID
  * @return {?string} Domain of this MXID
  */
-MatrixClient.prototype.getDomain = function() {
+MatrixClient.prototype.getDomain = function () {
     if (this.credentials && this.credentials.userId) {
         return this.credentials.userId.replace(/^.*?:/, '');
     }
     return null;
 };
 
 /**
  * Get the local part of the current user ID e.g. "foo" in "@foo:bar".
  * @return {?string} The user ID localpart or null.
  */
-MatrixClient.prototype.getUserIdLocalpart = function() {
+MatrixClient.prototype.getUserIdLocalpart = function () {
     if (this.credentials && this.credentials.userId) {
         return this.credentials.userId.split(":")[0].substring(1);
     }
     return null;
 };
 
 /**
  * Get the device ID of this client
  * @return {?string} device ID
  */
-MatrixClient.prototype.getDeviceId = function() {
+MatrixClient.prototype.getDeviceId = function () {
     return this.deviceId;
 };
 
-
 /**
  * Check if the runtime environment supports VoIP calling.
  * @return {boolean} True if VoIP is supported.
  */
-MatrixClient.prototype.supportsVoip = function() {
+MatrixClient.prototype.supportsVoip = function () {
     return this._supportsVoip;
 };
 
 /**
+ * Set whether VoIP calls are forced to use only TURN
+ * candidates. This is the same as the forceTURN option
+ * when creating the client.
+ * @param {bool} forceTURN True to force use of TURN servers
+ */
+MatrixClient.prototype.setForceTURN = function (forceTURN) {
+    this._forceTURN = forceTURN;
+};
+
+/**
  * Get the current sync state.
  * @return {?string} the sync state, which may be null.
  * @see module:client~MatrixClient#event:"sync"
  */
-MatrixClient.prototype.getSyncState = function() {
-    if (!this._syncApi) { return null; }
+MatrixClient.prototype.getSyncState = function () {
+    if (!this._syncApi) {
+        return null;
+    }
     return this._syncApi.getSyncState();
 };
 
 /**
+ * Returns the additional data object associated with
+ * the current sync state, or null if there is no
+ * such data.
+ * Sync errors, if available, are put in the 'error' key of
+ * this object.
+ * @return {?Object}
+ */
+MatrixClient.prototype.getSyncStateData = function () {
+    if (!this._syncApi) {
+        return null;
+    }
+    return this._syncApi.getSyncStateData();
+};
+
+/**
  * Return whether the client is configured for a guest account.
  * @return {boolean} True if this is a guest access_token (or no token is supplied).
  */
-MatrixClient.prototype.isGuest = function() {
+MatrixClient.prototype.isGuest = function () {
     return this._isGuest;
 };
 
 /**
  * Return the provided scheduler, if any.
  * @return {?module:scheduler~MatrixScheduler} The scheduler or null
  */
-MatrixClient.prototype.getScheduler = function() {
+MatrixClient.prototype.getScheduler = function () {
     return this.scheduler;
 };
 
 /**
  * Set whether this client is a guest account. <b>This method is experimental
  * and may change without warning.</b>
  * @param {boolean} isGuest True if this is a guest account.
  */
-MatrixClient.prototype.setGuest = function(isGuest) {
+MatrixClient.prototype.setGuest = function (isGuest) {
     // EXPERIMENTAL:
     // If the token is a macaroon, it should be encoded in it that it is a 'guest'
     // access token, which means that the SDK can determine this entirely without
     // the dev manually flipping this flag.
     this._isGuest = isGuest;
 };
 
 /**
  * Retry a backed off syncing request immediately. This should only be used when
  * the user <b>explicitly</b> attempts to retry their lost connection.
  * @return {boolean} True if this resulted in a request being retried.
  */
-MatrixClient.prototype.retryImmediately = function() {
+MatrixClient.prototype.retryImmediately = function () {
     return this._syncApi.retryImmediately();
 };
 
 /**
  * Return the global notification EventTimelineSet, if any
  *
  * @return {EventTimelineSet} the globl notification EventTimelineSet
  */
-MatrixClient.prototype.getNotifTimelineSet = function() {
+MatrixClient.prototype.getNotifTimelineSet = function () {
     return this._notifTimelineSet;
 };
 
 /**
  * Set the global notification EventTimelineSet
  *
  * @param {EventTimelineSet} notifTimelineSet
  */
-MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) {
+MatrixClient.prototype.setNotifTimelineSet = function (notifTimelineSet) {
     this._notifTimelineSet = notifTimelineSet;
 };
 
+/**
+ * Gets the capabilities of the homeserver. Always returns an object of
+ * capability keys and their options, which may be empty.
+ * @param {boolean} fresh True to ignore any cached values.
+ * @return {module:client.Promise} Resolves to the capabilities of the homeserver
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.getCapabilities = function (fresh = false) {
+    const now = new Date().getTime();
+
+    if (this._cachedCapabilities && !fresh) {
+        if (now < this._cachedCapabilities.expiration) {
+            _logger2.default.log("Returning cached capabilities");
+            return _bluebird2.default.resolve(this._cachedCapabilities.capabilities);
+        }
+    }
+
+    // We swallow errors because we need a default object anyhow
+    return this._http.authedRequest(undefined, "GET", "/capabilities").catch(e => {
+        _logger2.default.error(e);
+        return null; // otherwise consume the error
+    }).then(r => {
+        if (!r) r = {};
+        const capabilities = r["capabilities"] || {};
+
+        // If the capabilities missed the cache, cache it for a shorter amount
+        // of time to try and refresh them later.
+        const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
+
+        this._cachedCapabilities = {
+            capabilities: capabilities,
+            expiration: now + cacheMs
+        };
+
+        _logger2.default.log("Caching capabilities: ", capabilities);
+        return capabilities;
+    });
+};
+
 // Crypto bits
 // ===========
 
 /**
+ * Initialise support for end-to-end encryption in this client
+ *
+ * You should call this method after creating the matrixclient, but *before*
+ * calling `startClient`, if you want to support end-to-end encryption.
+ *
+ * It will return a Promise which will resolve when the crypto layer has been
+ * successfully initialised.
+ */
+MatrixClient.prototype.initCrypto = async function () {
+    if (!(0, _crypto.isCryptoAvailable)()) {
+        throw new Error(`End-to-end encryption not supported in this js-sdk build: did ` + `you remember to load the olm library?`);
+    }
+
+    if (this._crypto) {
+        _logger2.default.warn("Attempt to re-initialise e2e encryption on MatrixClient");
+        return;
+    }
+
+    if (!this._sessionStore) {
+        // this is temporary, the sessionstore is supposed to be going away
+        throw new Error(`Cannot enable encryption: no sessionStore provided`);
+    }
+    if (!this._cryptoStore) {
+        // the cryptostore is provided by sdk.createClient, so this shouldn't happen
+        throw new Error(`Cannot enable encryption: no cryptoStore provided`);
+    }
+
+    // initialise the list of encrypted rooms (whether or not crypto is enabled)
+    _logger2.default.log("Crypto: initialising roomlist...");
+    await this._roomList.init();
+
+    const userId = this.getUserId();
+    if (userId === null) {
+        throw new Error(`Cannot enable encryption on MatrixClient with unknown userId: ` + `ensure userId is passed in createClient().`);
+    }
+    if (this.deviceId === null) {
+        throw new Error(`Cannot enable encryption on MatrixClient with unknown deviceId: ` + `ensure deviceId is passed in createClient().`);
+    }
+
+    const crypto = new _crypto2.default(this, this._sessionStore, userId, this.deviceId, this.store, this._cryptoStore, this._roomList, this._verificationMethods);
+
+    this.reEmitter.reEmit(crypto, ["crypto.keyBackupFailed", "crypto.keyBackupSessionsRemaining", "crypto.roomKeyRequest", "crypto.roomKeyRequestCancellation", "crypto.warning"]);
+
+    _logger2.default.log("Crypto: initialising crypto object...");
+    await crypto.init();
+
+    this.olmVersion = _crypto2.default.getOlmVersion();
+
+    // if crypto initialisation was successful, tell it to attach its event
+    // handlers.
+    crypto.registerEventHandlers(this);
+    this._crypto = crypto;
+};
+
+/**
  * Is end-to-end crypto enabled for this client.
  * @return {boolean} True if end-to-end is enabled.
  */
-MatrixClient.prototype.isCryptoEnabled = function() {
+MatrixClient.prototype.isCryptoEnabled = function () {
     return this._crypto !== null;
 };
 
-
 /**
  * Get the Ed25519 key for this device
  *
  * @return {?string} base64-encoded ed25519 key. Null if crypto is
  *    disabled.
  */
-MatrixClient.prototype.getDeviceEd25519Key = function() {
+MatrixClient.prototype.getDeviceEd25519Key = function () {
     if (!this._crypto) {
         return null;
     }
     return this._crypto.getDeviceEd25519Key();
 };
 
 /**
- * Upload the device keys to the homeserver and ensure that the
- * homeserver has enough one-time keys.
- * @param {number} maxKeys The maximum number of keys to generate
+ * Upload the device keys to the homeserver.
  * @return {object} A promise that will resolve when the keys are uploaded.
  */
-MatrixClient.prototype.uploadKeys = function(maxKeys) {
+MatrixClient.prototype.uploadKeys = function () {
     if (this._crypto === null) {
         throw new Error("End-to-end encryption disabled");
     }
 
-    return this._crypto.uploadKeys(maxKeys);
+    return this._crypto.uploadDeviceKeys();
 };
 
 /**
  * Download the keys for a list of users and stores the keys in the session
  * store.
  * @param {Array} userIds The users to fetch.
  * @param {bool} forceDownload Always download the keys even if cached.
  *
  * @return {Promise} A promise which resolves to a map userId->deviceId->{@link
  * module:crypto~DeviceInfo|DeviceInfo}.
  */
-MatrixClient.prototype.downloadKeys = function(userIds, forceDownload) {
+MatrixClient.prototype.downloadKeys = function (userIds, forceDownload) {
     if (this._crypto === null) {
-        return q.reject(new Error("End-to-end encryption disabled"));
+        return _bluebird2.default.reject(new Error("End-to-end encryption disabled"));
     }
     return this._crypto.downloadKeys(userIds, forceDownload);
 };
 
 /**
- * List the stored device keys for a user id
- *
- * @deprecated prefer {@link module:client#getStoredDevicesForUser}
- *
- * @param {string} userId the user to list keys for.
- *
- * @return {object[]} list of devices with "id", "verified", "blocked",
- *    "key", and "display_name" parameters.
- */
-MatrixClient.prototype.listDeviceKeys = function(userId) {
-    if (this._crypto === null) {
-        throw new Error("End-to-end encryption disabled");
-    }
-    return this._crypto.listDeviceKeys(userId);
-};
-
-/**
  * Get the stored device keys for a user id
  *
  * @param {string} userId the user to list keys for.
  *
- * @return {module:crypto-deviceinfo[]} list of devices
+ * @return {Promise<module:crypto-deviceinfo[]>} list of devices
  */
-MatrixClient.prototype.getStoredDevicesForUser = function(userId) {
+MatrixClient.prototype.getStoredDevicesForUser = async function (userId) {
     if (this._crypto === null) {
         throw new Error("End-to-end encryption disabled");
     }
     return this._crypto.getStoredDevicesForUser(userId) || [];
 };
 
-
+/**
+ * Get the stored device key for a user id and device id
+ *
+ * @param {string} userId the user to list keys for.
+ * @param {string} deviceId unique identifier for the device
+ *
+ * @return {Promise<?module:crypto-deviceinfo>} device or null
+ */
+MatrixClient.prototype.getStoredDevice = async function (userId, deviceId) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    return this._crypto.getStoredDevice(userId, deviceId) || null;
+};
 
 /**
  * Mark the given device as verified
  *
  * @param {string} userId owner of the device
  * @param {string} deviceId unique identifier for the device
  *
  * @param {boolean=} verified whether to mark the device as verified. defaults
  *   to 'true'.
  *
+ * @returns {Promise}
+ *
  * @fires module:client~event:MatrixClient"deviceVerificationChanged"
  */
-MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified) {
+MatrixClient.prototype.setDeviceVerified = function (userId, deviceId, verified) {
     if (verified === undefined) {
         verified = true;
     }
-    _setDeviceVerification(this, userId, deviceId, verified, null);
+    const prom = _setDeviceVerification(this, userId, deviceId, verified, null);
+
+    // if one of the user's own devices is being marked as verified / unverified,
+    // check the key backup status, since whether or not we use this depends on
+    // whether it has a signature from a verified device
+    if (userId == this.credentials.userId) {
+        this._crypto.checkKeyBackup();
+    }
+    return prom;
 };
 
-
 /**
  * Mark the given device as blocked/unblocked
  *
  * @param {string} userId owner of the device
  * @param {string} deviceId unique identifier for the device
  *
  * @param {boolean=} blocked whether to mark the device as blocked. defaults
  *   to 'true'.
  *
+ * @returns {Promise}
+ *
  * @fires module:client~event:MatrixClient"deviceVerificationChanged"
  */
-MatrixClient.prototype.setDeviceBlocked = function(userId, deviceId, blocked) {
+MatrixClient.prototype.setDeviceBlocked = function (userId, deviceId, blocked) {
     if (blocked === undefined) {
         blocked = true;
     }
-    _setDeviceVerification(this, userId, deviceId, null, blocked);
+    return _setDeviceVerification(this, userId, deviceId, null, blocked);
 };
 
-function _setDeviceVerification(client, userId, deviceId, verified, blocked) {
+/**
+ * Mark the given device as known/unknown
+ *
+ * @param {string} userId owner of the device
+ * @param {string} deviceId unique identifier for the device
+ *
+ * @param {boolean=} known whether to mark the device as known. defaults
+ *   to 'true'.
+ *
+ * @returns {Promise}
+ *
+ * @fires module:client~event:MatrixClient"deviceVerificationChanged"
+ */
+MatrixClient.prototype.setDeviceKnown = function (userId, deviceId, known) {
+    if (known === undefined) {
+        known = true;
+    }
+    return _setDeviceVerification(this, userId, deviceId, null, null, known);
+};
+
+async function _setDeviceVerification(client, userId, deviceId, verified, blocked, known) {
     if (!client._crypto) {
         throw new Error("End-to-End encryption disabled");
     }
-    client._crypto.setDeviceVerification(userId, deviceId, verified, blocked);
-    client.emit("deviceVerificationChanged", userId, deviceId);
+    const dev = await client._crypto.setDeviceVerification(userId, deviceId, verified, blocked, known);
+    client.emit("deviceVerificationChanged", userId, deviceId, dev);
 }
 
 /**
+ * Request a key verification from another user.
+ *
+ * @param {string} userId the user to request verification with
+ * @param {Array} methods array of verification methods to use.  Defaults to
+ *    all known methods
+ * @param {Array} devices array of device IDs to send requests to.  Defaults to
+ *    all devices owned by the user
+ *
+ * @returns {Promise<module:crypto/verification/Base>} resolves to a verifier
+ *    when the request is accepted by the other user
+ */
+MatrixClient.prototype.requestVerification = function (userId, methods, devices) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    return this._crypto.requestVerification(userId, methods, devices);
+};
+
+/**
+ * Begin a key verification.
+ *
+ * @param {string} method the verification method to use
+ * @param {string} userId the user to verify keys with
+ * @param {string} deviceId the device to verify
+ *
+ * @returns {module:crypto/verification/Base} a verification object
+ */
+MatrixClient.prototype.beginKeyVerification = function (method, userId, deviceId) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    return this._crypto.beginKeyVerification(method, userId, deviceId);
+};
+
+/**
+ * Set the global override for whether the client should ever send encrypted
+ * messages to unverified devices.  This provides the default for rooms which
+ * do not specify a value.
+ *
+ * @param {boolean} value whether to blacklist all unverified devices by default
+ */
+MatrixClient.prototype.setGlobalBlacklistUnverifiedDevices = function (value) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    this._crypto.setGlobalBlacklistUnverifiedDevices(value);
+};
+
+/**
+ * @return {boolean} whether to blacklist all unverified devices by default
+ */
+MatrixClient.prototype.getGlobalBlacklistUnverifiedDevices = function () {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    return this._crypto.getGlobalBlacklistUnverifiedDevices();
+};
+
+/**
  * Get e2e information on the device that sent an event
  *
  * @param {MatrixEvent} event event to be checked
  *
- * @return {module:crypto/deviceinfo?}
+ * @return {Promise<module:crypto/deviceinfo?>}
  */
-MatrixClient.prototype.getEventSenderDeviceInfo = function(event) {
+MatrixClient.prototype.getEventSenderDeviceInfo = async function (event) {
     if (!this._crypto) {
         return null;
     }
 
     return this._crypto.getEventSenderDeviceInfo(event);
 };
 
 /**
  * Check if the sender of an event is verified
  *
  * @param {MatrixEvent} event event to be checked
  *
  * @return {boolean} true if the sender of this event has been verified using
  * {@link module:client~MatrixClient#setDeviceVerified|setDeviceVerified}.
  */
-MatrixClient.prototype.isEventSenderVerified = function(event) {
-    var device = this.getEventSenderDeviceInfo(event);
+MatrixClient.prototype.isEventSenderVerified = async function (event) {
+    const device = await this.getEventSenderDeviceInfo(event);
     if (!device) {
         return false;
     }
     return device.isVerified();
 };
 
 /**
+ * Cancel a room key request for this event if one is ongoing and resend the
+ * request.
+ * @param  {MatrixEvent} event event of which to cancel and resend the room
+ *                            key request.
+ * @return {Promise} A promise that will resolve when the key request is queued
+ */
+MatrixClient.prototype.cancelAndResendEventRoomKeyRequest = function (event) {
+    return event.cancelAndResendKeyRequest(this._crypto, this.getUserId());
+};
+
+/**
  * Enable end-to-end encryption for a room.
  * @param {string} roomId The room ID to enable encryption in.
  * @param {object} config The encryption config for the room.
- * @return {Object} A promise that will resolve when encryption is setup.
+ * @return {Promise} A promise that will resolve when encryption is set up.
  */
-MatrixClient.prototype.setRoomEncryption = function(roomId, config) {
+MatrixClient.prototype.setRoomEncryption = function (roomId, config) {
     if (!this._crypto) {
         throw new Error("End-to-End encryption disabled");
     }
-    this._crypto.setRoomEncryption(roomId, config);
-    return q();
+    return this._crypto.setRoomEncryption(roomId, config);
 };
 
 /**
  * Whether encryption is enabled for a room.
  * @param {string} roomId the room id to query.
  * @return {bool} whether encryption is enabled.
  */
-MatrixClient.prototype.isRoomEncrypted = function(roomId) {
-    if (!this._crypto) {
+MatrixClient.prototype.isRoomEncrypted = function (roomId) {
+    const room = this.getRoom(roomId);
+    if (!room) {
+        // we don't know about this room, so can't determine if it should be
+        // encrypted. Let's assume not.
         return false;
     }
 
-    return this._crypto.isRoomEncrypted(roomId);
+    // if there is an 'm.room.encryption' event in this room, it should be
+    // encrypted (independently of whether we actually support encryption)
+    const ev = room.currentState.getStateEvents("m.room.encryption", "");
+    if (ev) {
+        return true;
+    }
+
+    // we don't have an m.room.encrypted event, but that might be because
+    // the server is hiding it from us. Check the store to see if it was
+    // previously encrypted.
+    return this._roomList.isRoomEncrypted(roomId);
+};
+
+/**
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
+ *
+ * @param {string} roomId The ID of the room to discard the session for
+ *
+ * This should not normally be necessary.
+ */
+MatrixClient.prototype.forceDiscardSession = function (roomId) {
+    if (!this._crypto) {
+        throw new Error("End-to-End encryption disabled");
+    }
+    this._crypto.forceDiscardSession(roomId);
+};
+
+/**
+ * Get a list containing all of the room keys
+ *
+ * This should be encrypted before returning it to the user.
+ *
+ * @return {module:client.Promise} a promise which resolves to a list of
+ *    session export objects
+ */
+MatrixClient.prototype.exportRoomKeys = function () {
+    if (!this._crypto) {
+        return _bluebird2.default.reject(new Error("End-to-end encryption disabled"));
+    }
+    return this._crypto.exportRoomKeys();
+};
+
+/**
+ * Import a list of room keys previously exported by exportRoomKeys
+ *
+ * @param {Object[]} keys a list of session export objects
+ *
+ * @return {module:client.Promise} a promise which resolves when the keys
+ *    have been imported
+ */
+MatrixClient.prototype.importRoomKeys = function (keys) {
+    if (!this._crypto) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    return this._crypto.importRoomKeys(keys);
+};
+
+/**
+ * Force a re-check of the local key backup status against
+ * what's on the server.
+ *
+ * @returns {Object} Object with backup info (as returned by
+ *     getKeyBackupVersion) in backupInfo and
+ *     trust information (as returned by isKeyBackupTrusted)
+ *     in trustInfo.
+ */
+MatrixClient.prototype.checkKeyBackup = function () {
+    return this._crypto.checkKeyBackup();
+};
+
+/**
+ * Get information about the current key backup.
+ * @returns {Promise} Information object from API or null
+ */
+MatrixClient.prototype.getKeyBackupVersion = function () {
+    return this._http.authedRequest(undefined, "GET", "/room_keys/version", undefined, undefined, { prefix: httpApi.PREFIX_UNSTABLE }).then(res => {
+        if (res.algorithm !== olmlib.MEGOLM_BACKUP_ALGORITHM) {
+            const err = "Unknown backup algorithm: " + res.algorithm;
+            return _bluebird2.default.reject(err);
+        } else if (!(typeof res.auth_data === "object") || !res.auth_data.public_key) {
+            const err = "Invalid backup data returned";
+            return _bluebird2.default.reject(err);
+        } else {
+            return res;
+        }
+    }).catch(e => {
+        if (e.errcode === 'M_NOT_FOUND') {
+            return null;
+        } else {
+            throw e;
+        }
+    });
+};
+
+/**
+ * @param {object} info key backup info dict from getKeyBackupVersion()
+ * @return {object} {
+ *     usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
+ *     sigs: [
+ *         valid: [bool],
+ *         device: [DeviceInfo],
+ *     ]
+ * }
+ */
+MatrixClient.prototype.isKeyBackupTrusted = function (info) {
+    return this._crypto.isKeyBackupTrusted(info);
+};
+
+/**
+ * @returns {bool} true if the client is configured to back up keys to
+ *     the server, otherwise false.
+ */
+MatrixClient.prototype.getKeyBackupEnabled = function () {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    return Boolean(this._crypto.backupKey);
+};
+
+/**
+ * Enable backing up of keys, using data previously returned from
+ * getKeyBackupVersion.
+ *
+ * @param {object} info Backup information object as returned by getKeyBackupVersion
+ */
+MatrixClient.prototype.enableKeyBackup = function (info) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    this._crypto.backupInfo = info;
+    if (this._crypto.backupKey) this._crypto.backupKey.free();
+    this._crypto.backupKey = new global.Olm.PkEncryption();
+    this._crypto.backupKey.set_recipient_key(info.auth_data.public_key);
+
+    this.emit('crypto.keyBackupStatus', true);
+
+    // There may be keys left over from a partially completed backup, so
+    // schedule a send to check.
+    this._crypto.scheduleKeyBackupSend();
+};
+
+/**
+ * Disable backing up of keys.
+ */
+MatrixClient.prototype.disableKeyBackup = function () {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    this._crypto.backupInfo = null;
+    if (this._crypto.backupKey) this._crypto.backupKey.free();
+    this._crypto.backupKey = null;
+
+    this.emit('crypto.keyBackupStatus', false);
+};
+
+/**
+ * Set up the data required to create a new backup version.  The backup version
+ * will not be created and enabled until createKeyBackupVersion is called.
+ *
+ * @param {string} password Passphrase string that can be entered by the user
+ *     when restoring the backup as an alternative to entering the recovery key.
+ *     Optional.
+ *
+ * @returns {Promise<object>} Object that can be passed to createKeyBackupVersion and
+ *     additionally has a 'recovery_key' member with the user-facing recovery key string.
+ */
+MatrixClient.prototype.prepareKeyBackupVersion = async function (password) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    const decryption = new global.Olm.PkDecryption();
+    try {
+        let publicKey;
+        const authData = {};
+        if (password) {
+            const keyInfo = await (0, _backup_password.keyForNewBackup)(password);
+            publicKey = decryption.init_with_private_key(keyInfo.key);
+            authData.private_key_salt = keyInfo.salt;
+            authData.private_key_iterations = keyInfo.iterations;
+        } else {
+            publicKey = decryption.generate_key();
+        }
+
+        authData.public_key = publicKey;
+
+        return {
+            algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
+            auth_data: authData,
+            recovery_key: (0, _recoverykey.encodeRecoveryKey)(decryption.get_private_key())
+        };
+    } finally {
+        decryption.free();
+    }
 };
 
 /**
- * Decrypt a received event according to the algorithm specified in the event.
+ * Create a new key backup version and enable it, using the information return
+ * from prepareKeyBackupVersion.
  *
- * @param {MatrixClient} client
- * @param {MatrixEvent} event
+ * @param {object} info Info object from prepareKeyBackupVersion
+ * @returns {Promise<object>} Object with 'version' param indicating the version created
  */
-function _decryptEvent(client, event) {
-    if (!client._crypto) {
-        _badEncryptedMessage(event, "**Encryption not enabled**");
-        return;
+MatrixClient.prototype.createKeyBackupVersion = function (info) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    const data = {
+        algorithm: info.algorithm,
+        auth_data: info.auth_data
+    };
+    return this._crypto._signObject(data.auth_data).then(() => {
+        return this._http.authedRequest(undefined, "POST", "/room_keys/version", undefined, data, { prefix: httpApi.PREFIX_UNSTABLE });
+    }).then(res => {
+        this.enableKeyBackup({
+            algorithm: info.algorithm,
+            auth_data: info.auth_data,
+            version: res.version
+        });
+        return res;
+    });
+};
+
+MatrixClient.prototype.deleteKeyBackupVersion = function (version) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    // If we're currently backing up to this backup... stop.
+    // (We start using it automatically in createKeyBackupVersion
+    // so this is symmetrical).
+    if (this._crypto.backupInfo && this._crypto.backupInfo.version === version) {
+        this.disableKeyBackup();
+    }
+
+    const path = utils.encodeUri("/room_keys/version/$version", {
+        $version: version
+    });
+
+    return this._http.authedRequest(undefined, "DELETE", path, undefined, undefined, { prefix: httpApi.PREFIX_UNSTABLE });
+};
+
+MatrixClient.prototype._makeKeyBackupPath = function (roomId, sessionId, version) {
+    let path;
+    if (sessionId !== undefined) {
+        path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", {
+            $roomId: roomId,
+            $sessionId: sessionId
+        });
+    } else if (roomId !== undefined) {
+        path = utils.encodeUri("/room_keys/keys/$roomId", {
+            $roomId: roomId
+        });
+    } else {
+        path = "/room_keys/keys";
     }
-
+    const queryData = version === undefined ? undefined : { version: version };
+    return {
+        path: path,
+        queryData: queryData
+    };
+};
+
+/**
+ * Back up session keys to the homeserver.
+ * @param {string} roomId ID of the room that the keys are for Optional.
+ * @param {string} sessionId ID of the session that the keys are for Optional.
+ * @param {integer} version backup version Optional.
+ * @param {object} data Object keys to send
+ * @return {module:client.Promise} a promise that will resolve when the keys
+ * are uploaded
+ */
+MatrixClient.prototype.sendKeyBackup = function (roomId, sessionId, version, data) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    const path = this._makeKeyBackupPath(roomId, sessionId, version);
+    return this._http.authedRequest(undefined, "PUT", path.path, path.queryData, data, { prefix: httpApi.PREFIX_UNSTABLE });
+};
+
+/**
+ * Marks all group sessions as needing to be backed up and schedules them to
+ * upload in the background as soon as possible.
+ */
+MatrixClient.prototype.scheduleAllGroupSessionsForBackup = async function () {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    await this._crypto.scheduleAllGroupSessionsForBackup();
+};
+
+/**
+ * Marks all group sessions as needing to be backed up without scheduling
+ * them to upload in the background.
+ * @returns {Promise<int>} Resolves to the number of sessions requiring a backup.
+ */
+MatrixClient.prototype.flagAllGroupSessionsForBackup = function () {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    return this._crypto.flagAllGroupSessionsForBackup();
+};
+
+MatrixClient.prototype.isValidRecoveryKey = function (recoveryKey) {
     try {
-        client._crypto.decryptEvent(event);
+        (0, _recoverykey.decodeRecoveryKey)(recoveryKey);
+        return true;
     } catch (e) {
-        if (!(e instanceof Crypto.DecryptionError)) {
-            throw e;
-        }
-        _badEncryptedMessage(event, "**" + e.message + "**");
-        return;
+        return false;
+    }
+};
+
+MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY';
+
+MatrixClient.prototype.restoreKeyBackupWithPassword = async function (password, targetRoomId, targetSessionId, backupInfo) {
+    const privKey = await (0, _backup_password.keyForExistingBackup)(backupInfo, password);
+    return this._restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo);
+};
+
+MatrixClient.prototype.restoreKeyBackupWithRecoveryKey = function (recoveryKey, targetRoomId, targetSessionId, backupInfo) {
+    const privKey = (0, _recoverykey.decodeRecoveryKey)(recoveryKey);
+    return this._restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo);
+};
+
+MatrixClient.prototype._restoreKeyBackup = function (privKey, targetRoomId, targetSessionId, backupInfo) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+    let totalKeyCount = 0;
+    let keys = [];
+
+    const path = this._makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
+
+    const decryption = new global.Olm.PkDecryption();
+    let backupPubKey;
+    try {
+        backupPubKey = decryption.init_with_private_key(privKey);
+    } catch (e) {
+        decryption.free();
+        throw e;
+    }
+
+    // If the pubkey computed from the private data we've been given
+    // doesn't match the one in the auth_data, the user has enetered
+    // a different recovery key / the wrong passphrase.
+    if (backupPubKey !== backupInfo.auth_data.public_key) {
+        return _bluebird2.default.reject({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
     }
-}
-
-function _badEncryptedMessage(event, reason) {
-    event.setClearData({
-        type: "m.room.message",
-        content: {
-            msgtype: "m.bad.encrypted",
-            body: reason,
-        },
+
+    return this._http.authedRequest(undefined, "GET", path.path, path.queryData, undefined, { prefix: httpApi.PREFIX_UNSTABLE }).then(res => {
+        if (res.rooms) {
+            for (const [roomId, roomData] of Object.entries(res.rooms)) {
+                if (!roomData.sessions) continue;
+
+                totalKeyCount += Object.keys(roomData.sessions).length;
+                const roomKeys = keysFromRecoverySession(roomData.sessions, decryption, roomId, roomKeys);
+                for (const k of roomKeys) {
+                    k.room_id = roomId;
+                    keys.push(k);
+                }
+            }
+        } else if (res.sessions) {
+            totalKeyCount = Object.keys(res.sessions).length;
+            keys = keysFromRecoverySession(res.sessions, decryption, targetRoomId, keys);
+        } else {
+            totalKeyCount = 1;
+            try {
+                const key = keyFromRecoverySession(res, decryption);
+                key.room_id = targetRoomId;
+                key.session_id = targetSessionId;
+                keys.push(key);
+            } catch (e) {
+                _logger2.default.log("Failed to decrypt session from backup");
+            }
+        }
+
+        return this.importRoomKeys(keys);
+    }).then(() => {
+        return this._crypto.setTrustedBackupPubKey(backupPubKey);
+    }).then(() => {
+        return { total: totalKeyCount, imported: keys.length };
+    }).finally(() => {
+        decryption.free();
     });
-}
+};
+
+MatrixClient.prototype.deleteKeysFromBackup = function (roomId, sessionId, version) {
+    if (this._crypto === null) {
+        throw new Error("End-to-end encryption disabled");
+    }
+
+    const path = this._makeKeyBackupPath(roomId, sessionId, version);
+    return this._http.authedRequest(undefined, "DELETE", path.path, path.queryData, undefined, { prefix: httpApi.PREFIX_UNSTABLE });
+};
+
+// Group ops
+// =========
+// Operations on groups that come down the sync stream (ie. ones the
+// user is a member of or invited to)
+
+/**
+ * Get the group for the given group ID.
+ * This function will return a valid group for any group for which a Group event
+ * has been emitted.
+ * @param {string} groupId The group ID
+ * @return {Group} The Group or null if the group is not known or there is no data store.
+ */
+MatrixClient.prototype.getGroup = function (groupId) {
+    return this.store.getGroup(groupId);
+};
+
+/**
+ * Retrieve all known groups.
+ * @return {Group[]} A list of groups, or an empty list if there is no data store.
+ */
+MatrixClient.prototype.getGroups = function () {
+    return this.store.getGroups();
+};
+
+/**
+ * Get the config for the media repository.
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves with an object containing the config.
+ */
+MatrixClient.prototype.getMediaConfig = function (callback) {
+    return this._http.authedRequest(callback, "GET", "/config", undefined, undefined, {
+        prefix: httpApi.PREFIX_MEDIA_R0
+    });
+};
 
 // Room ops
 // ========
 
 /**
  * Get the room for the given room ID.
  * This function will return a valid room for any room for which a Room event
  * has been emitted. Note in particular that other events, eg. RoomState.members
  * will be emitted for a room before this function will return the given room.
  * @param {string} roomId The room ID
  * @return {Room} The Room or null if it doesn't exist or there is no data store.
  */
-MatrixClient.prototype.getRoom = function(roomId) {
+MatrixClient.prototype.getRoom = function (roomId) {
     return this.store.getRoom(roomId);
 };
 
 /**
  * Retrieve all known rooms.
  * @return {Room[]} A list of rooms, or an empty list if there is no data store.
  */
-MatrixClient.prototype.getRooms = function() {
+MatrixClient.prototype.getRooms = function () {
     return this.store.getRooms();
 };
 
 /**
+ * Retrieve all rooms that should be displayed to the user
+ * This is essentially getRooms() with some rooms filtered out, eg. old versions
+ * of rooms that have been replaced or (in future) other rooms that have been
+ * marked at the protocol level as not to be displayed to the user.
+ * @return {Room[]} A list of rooms, or an empty list if there is no data store.
+ */
+MatrixClient.prototype.getVisibleRooms = function () {
+    const allRooms = this.store.getRooms();
+
+    const replacedRooms = new Set();
+    for (const r of allRooms) {
+        const createEvent = r.currentState.getStateEvents('m.room.create', '');
+        // invites are included in this list and we don't know their create events yet
+        if (createEvent) {
+            const predecessor = createEvent.getContent()['predecessor'];
+            if (predecessor && predecessor['room_id']) {
+                replacedRooms.add(predecessor['room_id']);
+            }
+        }
+    }
+
+    return allRooms.filter(r => {
+        const tombstone = r.currentState.getStateEvents('m.room.tombstone', '');
+        if (tombstone && replacedRooms.has(r.roomId)) {
+            return false;
+        }
+        return true;
+    });
+};
+
+/**
  * Retrieve a user.
  * @param {string} userId The user ID to retrieve.
  * @return {?User} A user or null if there is no data store or the user does
  * not exist.
  */
-MatrixClient.prototype.getUser = function(userId) {
+MatrixClient.prototype.getUser = function (userId) {
     return this.store.getUser(userId);
 };
 
 /**
  * Retrieve all known users.
  * @return {User[]} A list of users, or an empty list if there is no data store.
  */
-MatrixClient.prototype.getUsers = function() {
+MatrixClient.prototype.getUsers = function () {
     return this.store.getUsers();
 };
 
 // User Account Data operations
 // ============================
 
 /**
  * Set account data event for the current user.
  * @param {string} eventType The event type
- * @param {Object} content the contents object for the event
+ * @param {Object} contents the contents object for the event
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setAccountData = function(eventType, contents, callback) {
-    var path = utils.encodeUri("/user/$userId/account_data/$type", {
+MatrixClient.prototype.setAccountData = function (eventType, contents, callback) {
+    const path = utils.encodeUri("/user/$userId/account_data/$type", {
         $userId: this.credentials.userId,
-        $type: eventType,
+        $type: eventType
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, contents
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, contents);
 };
 
 /**
  * Get account data event of given type for the current user.
  * @param {string} eventType The event type
- * @param {module:client.callback} callback Optional.
  * @return {?object} The contents of the given account data event
  */
-MatrixClient.prototype.getAccountData = function(eventType) {
+MatrixClient.prototype.getAccountData = function (eventType) {
     return this.store.getAccountData(eventType);
 };
 
+/**
+ * Gets the users that are ignored by this client
+ * @returns {string[]} The array of users that are ignored (empty if none)
+ */
+MatrixClient.prototype.getIgnoredUsers = function () {
+    const event = this.getAccountData("m.ignored_user_list");
+    if (!event || !event.getContent() || !event.getContent()["ignored_users"]) return [];
+    return Object.keys(event.getContent()["ignored_users"]);
+};
+
+/**
+ * Sets the users that the current user should ignore.
+ * @param {string[]} userIds the user IDs to ignore
+ * @param {module:client.callback} [callback] Optional.
+ * @return {module:client.Promise} Resolves: Account data event
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.setIgnoredUsers = function (userIds, callback) {
+    const content = { ignored_users: {} };
+    userIds.map(u => content.ignored_users[u] = {});
+    return this.setAccountData("m.ignored_user_list", content, callback);
+};
+
+/**
+ * Gets whether or not a specific user is being ignored by this client.
+ * @param {string} userId the user ID to check
+ * @returns {boolean} true if the user is ignored, false otherwise
+ */
+MatrixClient.prototype.isUserIgnored = function (userId) {
+    return this.getIgnoredUsers().indexOf(userId) !== -1;
+};
+
 // Room operations
 // ===============
 
 /**
  * Join a room. If you have already joined the room, this will no-op.
  * @param {string} roomIdOrAlias The room ID or room alias to join.
  * @param {Object} opts Options when joining the room.
  * @param {boolean} opts.syncRoom True to do a room initial sync on the resulting
  * room. If false, the <strong>returned Room object will have no current state.
  * </strong> Default: true.
  * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite,
  *                                     the signing URL is passed in this parameter.
+ * @param {string[]} opts.viaServers The server names to try and join through in
+ *                                   addition to those that are automatically chosen.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: Room object.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) {
+MatrixClient.prototype.joinRoom = function (roomIdOrAlias, opts, callback) {
     // to help people when upgrading..
     if (utils.isFunction(opts)) {
         throw new Error("Expected 'opts' object, got function.");
     }
     opts = opts || {};
-    if (opts.syncRoom === undefined) { opts.syncRoom = true; }
-
-    var room = this.getRoom(roomIdOrAlias);
+    if (opts.syncRoom === undefined) {
+        opts.syncRoom = true;
+    }
+
+    const room = this.getRoom(roomIdOrAlias);
     if (room && room.hasMembershipState(this.credentials.userId, "join")) {
-        return q(room);
+        return _bluebird2.default.resolve(room);
     }
 
-    var sign_promise = q();
+    let sign_promise = _bluebird2.default.resolve();
 
     if (opts.inviteSignUrl) {
-        sign_promise = this._http.requestOtherUrl(
-            undefined, 'POST',
-            opts.inviteSignUrl, { mxid: this.credentials.userId }
-        );
+        sign_promise = this._http.requestOtherUrl(undefined, 'POST', opts.inviteSignUrl, { mxid: this.credentials.userId });
+    }
+
+    const queryString = {};
+    if (opts.viaServers) {
+        queryString["server_name"] = opts.viaServers;
     }
 
-    var defer = q.defer();
-
-    var self = this;
-    sign_promise.then(function(signed_invite_object) {
-        var data = {};
+    const reqOpts = { qsStringifyOptions: { arrayFormat: 'repeat' } };
+
+    const defer = _bluebird2.default.defer();
+
+    const self = this;
+    sign_promise.then(function (signed_invite_object) {
+        const data = {};
         if (signed_invite_object) {
             data.third_party_signed = signed_invite_object;
         }
 
-        var path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias});
-        return self._http.authedRequest(undefined, "POST", path, undefined, data);
-    }).then(function(res) {
-        var roomId = res.room_id;
-        var syncApi = new SyncApi(self, self._clientOpts);
-        var room = syncApi.createRoom(roomId);
+        const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
+        return self._http.authedRequest(undefined, "POST", path, queryString, data, reqOpts);
+    }).then(function (res) {
+        const roomId = res.room_id;
+        const syncApi = new SyncApi(self, self._clientOpts);
+        const room = syncApi.createRoom(roomId);
         if (opts.syncRoom) {
             // v2 will do this for us
             // return syncApi.syncRoom(room);
         }
-        return q(room);
-    }).done(function(room) {
+        return _bluebird2.default.resolve(room);
+    }).done(function (room) {
         _resolve(callback, defer, room);
-    }, function(err) {
+    }, function (err) {
         _reject(callback, defer, err);
     });
     return defer.promise;
 };
 
 /**
  * Resend an event.
  * @param {MatrixEvent} event The event to resend.
  * @param {Room} room Optional. The room the event is in. Will update the
  * timeline entry if provided.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.resendEvent = function(event, room) {
+MatrixClient.prototype.resendEvent = function (event, room) {
     _updatePendingEventStatus(room, event, EventStatus.SENDING);
     return _sendEvent(this, room, event);
 };
 
 /**
  * Cancel a queued or unsent event.
  *
  * @param {MatrixEvent} event   Event to cancel
  * @throws Error if the event is not in QUEUED or NOT_SENT state
  */
-MatrixClient.prototype.cancelPendingEvent = function(event) {
+MatrixClient.prototype.cancelPendingEvent = function (event) {
     if ([EventStatus.QUEUED, EventStatus.NOT_SENT].indexOf(event.status) < 0) {
         throw new Error("cannot cancel an event with status " + event.status);
     }
 
     // first tell the scheduler to forget about it, if it's queued
     if (this.scheduler) {
         this.scheduler.removeEventFromQueue(event);
     }
 
     // then tell the room about the change of state, which will remove it
     // from the room's list of pending events.
-    var room = this.getRoom(event.getRoomId());
+    const room = this.getRoom(event.getRoomId());
     _updatePendingEventStatus(room, event, EventStatus.CANCELLED);
 };
 
 /**
  * @param {string} roomId
  * @param {string} name
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setRoomName = function(roomId, name, callback) {
-    return this.sendStateEvent(roomId, "m.room.name", {name: name},
-                               undefined, callback);
+MatrixClient.prototype.setRoomName = function (roomId, name, callback) {
+    return this.sendStateEvent(roomId, "m.room.name", { name: name }, undefined, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {string} topic
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setRoomTopic = function(roomId, topic, callback) {
-    return this.sendStateEvent(roomId, "m.room.topic", {topic: topic},
-                               undefined, callback);
+MatrixClient.prototype.setRoomTopic = function (roomId, topic, callback) {
+    return this.sendStateEvent(roomId, "m.room.topic", { topic: topic }, undefined, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.getRoomTags = function(roomId, callback) {
-    var path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", {
+MatrixClient.prototype.getRoomTags = function (roomId, callback) {
+    const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", {
         $userId: this.credentials.userId,
-        $roomId: roomId,
+        $roomId: roomId
     });
-    return this._http.authedRequest(
-        callback, "GET", path, undefined
-    );
+    return this._http.authedRequest(callback, "GET", path, undefined);
 };
 
 /**
  * @param {string} roomId
  * @param {string} tagName name of room tag to be set
  * @param {object} metadata associated with that tag to be stored
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setRoomTag = function(roomId, tagName, metadata, callback) {
-    var path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+MatrixClient.prototype.setRoomTag = function (roomId, tagName, metadata, callback) {
+    const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
         $userId: this.credentials.userId,
         $roomId: roomId,
-        $tag: tagName,
+        $tag: tagName
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, metadata
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, metadata);
 };
 
 /**
  * @param {string} roomId
  * @param {string} tagName name of room tag to be removed
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.deleteRoomTag = function(roomId, tagName, callback) {
-    var path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+MatrixClient.prototype.deleteRoomTag = function (roomId, tagName, callback) {
+    const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
         $userId: this.credentials.userId,
         $roomId: roomId,
-        $tag: tagName,
+        $tag: tagName
     });
-    return this._http.authedRequest(
-        callback, "DELETE", path, undefined, undefined
-    );
+    return this._http.authedRequest(callback, "DELETE", path, undefined, undefined);
 };
 
 /**
  * @param {string} roomId
  * @param {string} eventType event type to be set
  * @param {object} content event content
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setRoomAccountData = function(roomId, eventType,
-                                                     content, callback) {
-    var path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
+MatrixClient.prototype.setRoomAccountData = function (roomId, eventType, content, callback) {
+    const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
         $userId: this.credentials.userId,
         $roomId: roomId,
-        $type: eventType,
+        $type: eventType
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, content
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, content);
 };
 
 /**
  * Set a user's power level.
  * @param {string} roomId
  * @param {string} userId
  * @param {Number} powerLevel
  * @param {MatrixEvent} event
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setPowerLevel = function(roomId, userId, powerLevel,
-                                                event, callback) {
-    var content = {
+MatrixClient.prototype.setPowerLevel = function (roomId, userId, powerLevel, event, callback) {
+    let content = {
         users: {}
     };
     if (event && event.getType() === "m.room.power_levels") {
         // take a copy of the content to ensure we don't corrupt
         // existing client state with a failed power level change
         content = utils.deepCopy(event.getContent());
     }
     content.users[userId] = powerLevel;
-    var path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
+    const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
         $roomId: roomId
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, content
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, content);
 };
 
 /**
  * @param {string} roomId
  * @param {string} eventType
  * @param {Object} content
  * @param {string} txnId Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId,
-                                            callback) {
-    if (utils.isFunction(txnId)) { callback = txnId; txnId = undefined; }
+MatrixClient.prototype.sendEvent = function (roomId, eventType, content, txnId, callback) {
+    return this._sendCompleteEvent(roomId, {
+        type: eventType,
+        content: content
+    }, txnId, callback);
+};
+/**
+ * @param {string} roomId
+ * @param {object} eventObject An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added.
+ * @param {string} txnId the txnId.
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype._sendCompleteEvent = function (roomId, eventObject, txnId, callback) {
+    if (utils.isFunction(txnId)) {
+        callback = txnId;txnId = undefined;
+    }
 
     if (!txnId) {
         txnId = this.makeTxnId();
     }
 
     // we always construct a MatrixEvent when sending because the store and
     // scheduler use them. We'll extract the params back out if it turns out
     // the client has no scheduler or store.
-    var room = this.getRoom(roomId);
-    var localEvent = new MatrixEvent({
+    const localEvent = new MatrixEvent(Object.assign(eventObject, {
         event_id: "~" + roomId + ":" + txnId,
         user_id: this.credentials.userId,
         room_id: roomId,
-        type: eventType,
-        origin_server_ts: new Date().getTime(),
-        content: content
-    });
+        origin_server_ts: new Date().getTime()
+    }));
+
+    const room = this.getRoom(roomId);
+
+    // if this is a relation or redaction of an event
+    // that hasn't been sent yet (e.g. with a local id starting with a ~)
+    // then listen for the remote echo of that event so that by the time
+    // this event does get sent, we have the correct event_id
+    const targetId = localEvent.getAssociatedId();
+    if (targetId && targetId.startsWith("~")) {
+        const target = room.getPendingEvents().find(e => e.getId() === targetId);
+        target.once("Event.localEventIdReplaced", () => {
+            localEvent.updateAssociatedId(target.getId());
+        });
+    }
+
+    const type = localEvent.getType();
+    _logger2.default.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
+
     localEvent._txnId = txnId;
-    localEvent.status = EventStatus.SENDING;
+    localEvent.setStatus(EventStatus.SENDING);
 
     // add this event immediately to the local store as 'sending'.
     if (room) {
         room.addPendingEvent(localEvent, txnId);
     }
 
+    // addPendingEvent can change the state to NOT_SENT if it believes
+    // that there's other events that have failed. We won't bother to
+    // try sending the event if the state has changed as such.
+    if (localEvent.status === EventStatus.NOT_SENT) {
+        return _bluebird2.default.reject(new Error("Event blocked by other events not yet sent"));
+    }
+
     return _sendEvent(this, room, localEvent, callback);
 };
 
-
 // encrypts the event if necessary
 // adds the event to the queue, or sends it
 // marks the event as sent/unsent
 // returns a promise which resolves with the result of the send request
 function _sendEvent(client, room, event, callback) {
-    // Add an extra q() to turn synchronous exceptions into promise rejections,
+    // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections,
     // so that we can handle synchronous and asynchronous exceptions with the
     // same code path.
-    return q().then(function() {
-        var encryptionPromise = null;
-        if (client._crypto) {
-            encryptionPromise = client._crypto.encryptEventIfNeeded(event, room);
+    return _bluebird2.default.resolve().then(function () {
+        const encryptionPromise = _encryptEventIfNeeded(client, event, room);
+
+        if (!encryptionPromise) {
+            return null;
         }
-        if (encryptionPromise) {
-            _updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
-            encryptionPromise = encryptionPromise.then(function() {
-                _updatePendingEventStatus(room, event, EventStatus.SENDING);
-            });
-        }
-        return encryptionPromise;
-    }).then(function() {
-        var promise;
+
+        _updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
+        return encryptionPromise.then(() => {
+            _updatePendingEventStatus(room, event, EventStatus.SENDING);
+        });
+    }).then(function () {
+        let promise;
         // this event may be queued
         if (client.scheduler) {
             // if this returns a promsie then the scheduler has control now and will
             // resolve/reject when it is done. Internally, the scheduler will invoke
             // processFn which is set to this._sendEventHttpRequest so the same code
             // path is executed regardless.
             promise = client.scheduler.queueEvent(event);
             if (promise && client.scheduler.getQueueForEvent(event).length > 1) {
@@ -867,478 +1786,751 @@ function _sendEvent(client, room, event,
                 _updatePendingEventStatus(room, event, EventStatus.QUEUED);
             }
         }
 
         if (!promise) {
             promise = _sendEventHttpRequest(client, event);
         }
         return promise;
-    }).then(function(res) {  // the request was sent OK
+    }).then(function (res) {
+        // the request was sent OK
         if (room) {
             room.updatePendingEvent(event, EventStatus.SENT, res.event_id);
         }
         if (callback) {
             callback(null, res);
         }
         return res;
-    }, function(err) {
+    }, function (err) {
         // the request failed to send.
-        console.error("Error sending event", err.stack || err);
+        _logger2.default.error("Error sending event", err.stack || err);
 
         try {
+            // set the error on the event before we update the status:
+            // updating the status emits the event, so the state should be
+            // consistent at that point.
+            event.error = err;
             _updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
+            // also put the event object on the error: the caller will need this
+            // to resend or cancel the event
+            err.event = event;
 
             if (callback) {
                 callback(err);
             }
         } catch (err2) {
-            console.error("Exception in error handler!", err2.stack || err);
+            _logger2.default.error("Exception in error handler!", err2.stack || err);
         }
         throw err;
     });
 }
 
+/**
+ * Encrypt an event according to the configuration of the room, if necessary.
+ *
+ * @param {MatrixClient} client
+ *
+ * @param {module:models/event.MatrixEvent} event  event to be sent
+ *
+ * @param {module:models/room?} room destination room. Null if the destination
+ *     is not a room we have seen over the sync pipe.
+ *
+ * @return {module:client.Promise?} Promise which resolves when the event has been
+ *     encrypted, or null if nothing was needed
+ */
+
+function _encryptEventIfNeeded(client, event, room) {
+    if (event.isEncrypted()) {
+        // this event has already been encrypted; this happens if the
+        // encryption step succeeded, but the send step failed on the first
+        // attempt.
+        return null;
+    }
+
+    if (!client.isRoomEncrypted(event.getRoomId())) {
+        // looks like this room isn't encrypted.
+        return null;
+    }
+
+    if (event.getType() === "m.reaction") {
+        // For reactions, there is a very little gained by encrypting the entire
+        // event, as relation data is already kept in the clear. Event
+        // encryption for a reaction effectively only obscures the event type,
+        // but the purpose is still obvious from the relation data, so nothing
+        // is really gained. It also causes quite a few problems, such as:
+        //   * triggers notifications via default push rules
+        //   * prevents server-side bundling for reactions
+        // The reaction key / content / emoji value does warrant encrypting, but
+        // this will be handled separately by encrypting just this value.
+        // See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642
+        return null;
+    }
+
+    if (!client._crypto) {
+        throw new Error("This room is configured to use encryption, but your client does " + "not support encryption.");
+    }
+
+    return client._crypto.encryptEvent(event, room);
+}
+/**
+ * Returns the eventType that should be used taking encryption into account
+ * for a given eventType.
+ * @param {MatrixClient} client the client
+ * @param {string} roomId the room for the events `eventType` relates to
+ * @param {string} eventType the event type
+ * @return {string} the event type taking encryption into account
+ */
+function _getEncryptedIfNeededEventType(client, roomId, eventType) {
+    if (eventType === "m.reaction") {
+        return eventType;
+    }
+    const isEncrypted = client.isRoomEncrypted(roomId);
+    return isEncrypted ? "m.room.encrypted" : eventType;
+}
+
 function _updatePendingEventStatus(room, event, newStatus) {
     if (room) {
         room.updatePendingEvent(event, newStatus);
     } else {
-        event.status = newStatus;
+        event.setStatus(newStatus);
     }
 }
 
 function _sendEventHttpRequest(client, event) {
-    var txnId = event._txnId ? event._txnId : client.makeTxnId();
-
-    var pathParams = {
+    const txnId = event._txnId ? event._txnId : client.makeTxnId();
+
+    const pathParams = {
         $roomId: event.getRoomId(),
         $eventType: event.getWireType(),
         $stateKey: event.getStateKey(),
-        $txnId: txnId,
+        $txnId: txnId
     };
 
-    var path;
+    let path;
 
     if (event.isState()) {
-        var pathTemplate = "/rooms/$roomId/state/$eventType";
+        let pathTemplate = "/rooms/$roomId/state/$eventType";
         if (event.getStateKey() && event.getStateKey().length > 0) {
             pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey";
         }
         path = utils.encodeUri(pathTemplate, pathParams);
-    }
-    else {
-        path = utils.encodeUri(
-            "/rooms/$roomId/send/$eventType/$txnId", pathParams
-        );
+    } else if (event.isRedaction()) {
+        const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`;
+        path = utils.encodeUri(pathTemplate, Object.assign({
+            $redactsEventId: event.event.redacts
+        }, pathParams));
+    } else {
+        path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
     }
 
-    return client._http.authedRequest(
-        undefined, "PUT", path, undefined, event.getWireContent()
-    );
+    return client._http.authedRequest(undefined, "PUT", path, undefined, event.getWireContent()).then(res => {
+        _logger2.default.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
+        return res;
+    });
 }
 
 /**
  * @param {string} roomId
+ * @param {string} eventId
+ * @param {string} [txnId]  transaction id. One will be made up if not
+ *    supplied.
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.redactEvent = function (roomId, eventId, txnId, callback) {
+    return this._sendCompleteEvent(roomId, {
+        type: "m.room.redaction",
+        content: {},
+        redacts: eventId
+    }, txnId, callback);
+};
+
+/**
+ * @param {string} roomId
  * @param {Object} content
  * @param {string} txnId Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendMessage = function(roomId, content, txnId, callback) {
-    if (utils.isFunction(txnId)) { callback = txnId; txnId = undefined; }
-    return this.sendEvent(
-        roomId, "m.room.message", content, txnId, callback
-    );
+MatrixClient.prototype.sendMessage = function (roomId, content, txnId, callback) {
+    if (utils.isFunction(txnId)) {
+        callback = txnId;txnId = undefined;
+    }
+    return this.sendEvent(roomId, "m.room.message", content, txnId, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {string} body
  * @param {string} txnId Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendTextMessage = function(roomId, body, txnId, callback) {
-    var content = {
-         msgtype: "m.text",
-         body: body
-    };
+MatrixClient.prototype.sendTextMessage = function (roomId, body, txnId, callback) {
+    const content = ContentHelpers.makeTextMessage(body);
     return this.sendMessage(roomId, content, txnId, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {string} body
  * @param {string} txnId Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendNotice = function(roomId, body, txnId, callback) {
-    var content = {
-         msgtype: "m.notice",
-         body: body
-    };
+MatrixClient.prototype.sendNotice = function (roomId, body, txnId, callback) {
+    const content = ContentHelpers.makeNotice(body);
     return this.sendMessage(roomId, content, txnId, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {string} body
  * @param {string} txnId Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendEmoteMessage = function(roomId, body, txnId, callback) {
-    var content = {
-         msgtype: "m.emote",
-         body: body
-    };
+MatrixClient.prototype.sendEmoteMessage = function (roomId, body, txnId, callback) {
+    const content = ContentHelpers.makeEmoteMessage(body);
     return this.sendMessage(roomId, content, txnId, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {string} url
  * @param {Object} info
  * @param {string} text
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendImageMessage = function(roomId, url, info, text, callback) {
-    if (utils.isFunction(text)) { callback = text; text = undefined; }
-    if (!text) { text = "Image"; }
-    var content = {
-         msgtype: "m.image",
-         url: url,
-         info: info,
-         body: text
+MatrixClient.prototype.sendImageMessage = function (roomId, url, info, text, callback) {
+    if (utils.isFunction(text)) {
+        callback = text;text = undefined;
+    }
+    if (!text) {
+        text = "Image";
+    }
+    const content = {
+        msgtype: "m.image",
+        url: url,
+        info: info,
+        body: text
     };
     return this.sendMessage(roomId, content, callback);
 };
 
 /**
  * @param {string} roomId
+ * @param {string} url
+ * @param {Object} info
+ * @param {string} text
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.sendStickerMessage = function (roomId, url, info, text, callback) {
+    if (utils.isFunction(text)) {
+        callback = text;text = undefined;
+    }
+    if (!text) {
+        text = "Sticker";
+    }
+    const content = {
+        url: url,
+        info: info,
+        body: text
+    };
+    return this.sendEvent(roomId, "m.sticker", content, callback, undefined);
+};
+
+/**
+ * @param {string} roomId
+ * @param {string} body
+ * @param {string} htmlBody
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.sendHtmlMessage = function (roomId, body, htmlBody, callback) {
+    const content = ContentHelpers.makeHtmlMessage(body, htmlBody);
+    return this.sendMessage(roomId, content, callback);
+};
+
+/**
+ * @param {string} roomId
  * @param {string} body
  * @param {string} htmlBody
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendHtmlMessage = function(roomId, body, htmlBody, callback) {
-    var content = {
-        msgtype: "m.text",
-        format: "org.matrix.custom.html",
-        body: body,
-        formatted_body: htmlBody
-    };
+MatrixClient.prototype.sendHtmlNotice = function (roomId, body, htmlBody, callback) {
+    const content = ContentHelpers.makeHtmlNotice(body, htmlBody);
     return this.sendMessage(roomId, content, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {string} body
  * @param {string} htmlBody
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendHtmlNotice = function(roomId, body, htmlBody, callback) {
-    var content = {
-        msgtype: "m.notice",
-        format: "org.matrix.custom.html",
-        body: body,
-        formatted_body: htmlBody
-    };
-    return this.sendMessage(roomId, content, callback);
-};
-
-/**
- * @param {string} roomId
- * @param {string} body
- * @param {string} htmlBody
- * @param {module:client.callback} callback Optional.
- * @return {module:client.Promise} Resolves: TODO
- * @return {module:http-api.MatrixError} Rejects: with an error response.
- */
-MatrixClient.prototype.sendHtmlEmote = function(roomId, body, htmlBody, callback) {
-    var content = {
-        msgtype: "m.emote",
-        format: "org.matrix.custom.html",
-        body: body,
-        formatted_body: htmlBody
-    };
+MatrixClient.prototype.sendHtmlEmote = function (roomId, body, htmlBody, callback) {
+    const content = ContentHelpers.makeHtmlEmote(body, htmlBody);
     return this.sendMessage(roomId, content, callback);
 };
 
 /**
  * Send a receipt.
  * @param {Event} event The event being acknowledged
  * @param {string} receiptType The kind of receipt e.g. "m.read"
+ * @param {object} opts Additional content to send alongside the receipt.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendReceipt = function(event, receiptType, callback) {
+MatrixClient.prototype.sendReceipt = function (event, receiptType, opts, callback) {
+    if (typeof opts === 'function') {
+        callback = opts;
+        opts = {};
+    }
+
     if (this.isGuest()) {
-        return q({}); // guests cannot send receipts so don't bother.
+        return _bluebird2.default.resolve({}); // guests cannot send receipts so don't bother.
     }
 
-    var path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
+    const path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
         $roomId: event.getRoomId(),
         $receiptType: receiptType,
         $eventId: event.getId()
     });
-    var promise = this._http.authedRequest(
-        callback, "POST", path, undefined, {}
-    );
-
-    var room = this.getRoom(event.getRoomId());
+    const promise = this._http.authedRequest(callback, "POST", path, undefined, opts || {});
+
+    const room = this.getRoom(event.getRoomId());
     if (room) {
         room._addLocalEchoReceipt(this.credentials.userId, event, receiptType);
     }
     return promise;
 };
 
 /**
  * Send a read receipt.
  * @param {Event} event The event that has been read.
+ * @param {object} opts The options for the read receipt.
+ * @param {boolean} opts.hidden True to prevent the receipt from being sent to
+ * other users and homeservers. Default false (send to everyone). <b>This
+ * property is unstable and may change in the future.</b>
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendReadReceipt = function(event, callback) {
-    return this.sendReceipt(event, "m.read", callback);
+MatrixClient.prototype.sendReadReceipt = async function (event, opts, callback) {
+    if (typeof opts === 'function') {
+        callback = opts;
+        opts = {};
+    }
+    if (!opts) opts = {};
+
+    const eventId = event.getId();
+    const room = this.getRoom(event.getRoomId());
+    if (room && room.hasPendingEvent(eventId)) {
+        throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
+    }
+
+    const addlContent = {
+        "m.hidden": Boolean(opts.hidden)
+    };
+
+    return this.sendReceipt(event, "m.read", addlContent, callback);
 };
 
+/**
+ * Set a marker to indicate the point in a room before which the user has read every
+ * event. This can be retrieved from room account data (the event type is `m.fully_read`)
+ * and displayed as a horizontal line in the timeline that is visually distinct to the
+ * position of the user's own read receipt.
+ * @param {string} roomId ID of the room that has been read
+ * @param {string} rmEventId ID of the event that has been read
+ * @param {string} rrEvent the event tracked by the read receipt. This is here for
+ * convenience because the RR and the RM are commonly updated at the same time as each
+ * other. The local echo of this receipt will be done if set. Optional.
+ * @param {object} opts Options for the read markers
+ * @param {object} opts.hidden True to hide the receipt from other users and homeservers.
+ * <b>This property is unstable and may change in the future.</b>
+ * @return {module:client.Promise} Resolves: the empty object, {}.
+ */
+MatrixClient.prototype.setRoomReadMarkers = async function (roomId, rmEventId, rrEvent, opts) {
+    const room = this.getRoom(roomId);
+    if (room && room.hasPendingEvent(rmEventId)) {
+        throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
+    }
+
+    // Add the optional RR update, do local echo like `sendReceipt`
+    let rrEventId;
+    if (rrEvent) {
+        rrEventId = rrEvent.getId();
+        if (room && room.hasPendingEvent(rrEventId)) {
+            throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`);
+        }
+        if (room) {
+            room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read");
+        }
+    }
+
+    return this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, opts);
+};
 
 /**
  * Get a preview of the given URL as of (roughly) the given point in time,
  * described as an object with OpenGraph keys and associated values.
  * Attributes may be synthesized where actual OG metadata is lacking.
  * Caches results to prevent hammering the server.
  * @param {string} url The URL to get preview data for
  * @param {Number} ts The preferred point in time that the preview should
  * describe (ms since epoch).  The preview returned will either be the most
  * recent one preceding this timestamp if available, or failing that the next
  * most recent available preview.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: Object of OG metadata.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  * May return synthesized attributes if the URL lacked OG meta.
  */
-MatrixClient.prototype.getUrlPreview = function(url, ts, callback) {
-    var key = ts + "_" + url;
-    var og = this.urlPreviewCache[key];
+MatrixClient.prototype.getUrlPreview = function (url, ts, callback) {
+    const key = ts + "_" + url;
+    const og = this.urlPreviewCache[key];
     if (og) {
-        return q(og);
+        return _bluebird2.default.resolve(og);
     }
 
-    var self = this;
-    return this._http.authedRequestWithPrefix(
-        callback, "GET", "/preview_url", {
-            url: url,
-            ts: ts,
-        }, undefined, httpApi.PREFIX_MEDIA_R0
-    ).then(function(response) {
+    const self = this;
+    return this._http.authedRequest(callback, "GET", "/preview_url", {
+        url: url,
+        ts: ts
+    }, undefined, {
+        prefix: httpApi.PREFIX_MEDIA_R0
+    }).then(function (response) {
         // TODO: expire cache occasionally
         self.urlPreviewCache[key] = response;
         return response;
     });
 };
 
 /**
  * @param {string} roomId
  * @param {boolean} isTyping
  * @param {Number} timeoutMs
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.sendTyping = function(roomId, isTyping, timeoutMs, callback) {
+MatrixClient.prototype.sendTyping = function (roomId, isTyping, timeoutMs, callback) {
     if (this.isGuest()) {
-        return q({}); // guests cannot send typing notifications so don't bother.
+        return _bluebird2.default.resolve({}); // guests cannot send typing notifications so don't bother.
     }
 
-    var path = utils.encodeUri("/rooms/$roomId/typing/$userId", {
+    const path = utils.encodeUri("/rooms/$roomId/typing/$userId", {
         $roomId: roomId,
         $userId: this.credentials.userId
     });
-    var data = {
+    const data = {
         typing: isTyping
     };
     if (isTyping) {
         data.timeout = timeoutMs ? timeoutMs : 20000;
     }
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, data
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, data);
+};
+
+/**
+ * Determines the history of room upgrades for a given room, as far as the
+ * client can see. Returns an array of Rooms where the first entry is the
+ * oldest and the last entry is the newest (likely current) room. If the
+ * provided room is not found, this returns an empty list. This works in
+ * both directions, looking for older and newer rooms of the given room.
+ * @param {string} roomId The room ID to search from
+ * @param {boolean} verifyLinks If true, the function will only return rooms
+ * which can be proven to be linked. For example, rooms which have a create
+ * event pointing to an old room which the client is not aware of or doesn't
+ * have a matching tombstone would not be returned.
+ * @return {Room[]} An array of rooms representing the upgrade
+ * history.
+ */
+MatrixClient.prototype.getRoomUpgradeHistory = function (roomId, verifyLinks = false) {
+    let currentRoom = this.getRoom(roomId);
+    if (!currentRoom) return [];
+
+    const upgradeHistory = [currentRoom];
+
+    // Work backwards first, looking at create events.
+    let createEvent = currentRoom.currentState.getStateEvents("m.room.create", "");
+    while (createEvent) {
+        _logger2.default.log(`Looking at ${createEvent.getId()}`);
+        const predecessor = createEvent.getContent()['predecessor'];
+        if (predecessor && predecessor['room_id']) {
+            _logger2.default.log(`Looking at predecessor ${predecessor['room_id']}`);
+            const refRoom = this.getRoom(predecessor['room_id']);
+            if (!refRoom) break; // end of the chain
+
+            if (verifyLinks) {
+                const tombstone = refRoom.currentState.getStateEvents("m.room.tombstone", "");
+
+                if (!tombstone || tombstone.getContent()['replacement_room'] !== refRoom.roomId) {
+                    break;
+                }
+            }
+
+            // Insert at the front because we're working backwards from the currentRoom
+            upgradeHistory.splice(0, 0, refRoom);
+            createEvent = refRoom.currentState.getStateEvents("m.room.create", "");
+        } else {
+            // No further create events to look at
+            break;
+        }
+    }
+
+    // Work forwards next, looking at tombstone events
+    let tombstoneEvent = currentRoom.currentState.getStateEvents("m.room.tombstone", "");
+    while (tombstoneEvent) {
+        const refRoom = this.getRoom(tombstoneEvent.getContent()['replacement_room']);
+        if (!refRoom) break; // end of the chain
+        if (refRoom.roomId === currentRoom.roomId) break; // Tombstone is referencing it's own room
+
+        if (verifyLinks) {
+            createEvent = refRoom.currentState.getStateEvents("m.room.create", "");
+            if (!createEvent || !createEvent.getContent()['predecessor']) break;
+
+            const predecessor = createEvent.getContent()['predecessor'];
+            if (predecessor['room_id'] !== currentRoom.roomId) break;
+        }
+
+        // Push to the end because we're looking forwards
+        upgradeHistory.push(refRoom);
+        const roomIds = new Set(upgradeHistory.map(ref => ref.roomId));
+        if (roomIds.size < upgradeHistory.length) {
+            // The last room added to the list introduced a previous roomId
+            // To avoid recursion, return the last rooms - 1
+            return upgradeHistory.slice(0, upgradeHistory.length - 1);
+        }
+
+        // Set the current room to the reference room so we know where we're at
+        currentRoom = refRoom;
+        tombstoneEvent = currentRoom.currentState.getStateEvents("m.room.tombstone", "");
+    }
+
+    return upgradeHistory;
 };
 
 /**
  * @param {string} roomId
  * @param {string} userId
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.invite = function(roomId, userId, callback) {
-    return _membershipChange(this, roomId, userId, "invite", undefined,
-        callback);
+MatrixClient.prototype.invite = function (roomId, userId, callback) {
+    return _membershipChange(this, roomId, userId, "invite", undefined, callback);
 };
 
 /**
  * Invite a user to a room based on their email address.
  * @param {string} roomId The room to invite the user to.
  * @param {string} email The email address to invite.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.inviteByEmail = function(roomId, email, callback) {
-    return this.inviteByThreePid(
-        roomId, "email", email, callback
-    );
+MatrixClient.prototype.inviteByEmail = function (roomId, email, callback) {
+    return this.inviteByThreePid(roomId, "email", email, callback);
 };
 
 /**
  * Invite a user to a room based on a third-party identifier.
  * @param {string} roomId The room to invite the user to.
  * @param {string} medium The medium to invite the user e.g. "email".
  * @param {string} address The address for the specified medium.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.inviteByThreePid = function(roomId, medium, address, callback) {
-    var path = utils.encodeUri(
-        "/rooms/$roomId/invite",
-        { $roomId: roomId }
-    );
-
-    var identityServerUrl = this.getIdentityServerUrl();
+MatrixClient.prototype.inviteByThreePid = async function (roomId, medium, address, callback) {
+    const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId });
+
+    const identityServerUrl = this.getIdentityServerUrl(true);
     if (!identityServerUrl) {
-        return q.reject(new MatrixError({
+        return _bluebird2.default.reject(new MatrixError({
             error: "No supplied identity server URL",
             errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM"
         }));
     }
-    if (identityServerUrl.indexOf("http://") === 0 ||
-            identityServerUrl.indexOf("https://") === 0) {
-        // this request must not have the protocol part because reasons
-        identityServerUrl = identityServerUrl.split("://")[1];
-    }
-
-    return this._http.authedRequest(callback, "POST", path, undefined, {
+    const params = {
         id_server: identityServerUrl,
         medium: medium,
         address: address
-    });
+    };
+
+    if (this.identityServer && this.identityServer.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
+        const identityAccessToken = await this.identityServer.getAccessToken();
+        if (identityAccessToken) {
+            params.id_access_token = identityAccessToken;
+        }
+    }
+
+    return this._http.authedRequest(callback, "POST", path, undefined, params);
 };
 
 /**
  * @param {string} roomId
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.leave = function(roomId, callback) {
-    return _membershipChange(this, roomId, undefined, "leave", undefined,
-        callback);
+MatrixClient.prototype.leave = function (roomId, callback) {
+    return _membershipChange(this, roomId, undefined, "leave", undefined, callback);
+};
+
+/**
+ * Leaves all rooms in the chain of room upgrades based on the given room. By
+ * default, this will leave all the previous and upgraded rooms, including the
+ * given room. To only leave the given room and any previous rooms, keeping the
+ * upgraded (modern) rooms untouched supply `false` to `includeFuture`.
+ * @param {string} roomId The room ID to start leaving at
+ * @param {boolean} includeFuture If true, the whole chain (past and future) of
+ * upgraded rooms will be left.
+ * @return {module:client.Promise} Resolves when completed with an object keyed
+ * by room ID and value of the error encountered when leaving or null.
+ */
+MatrixClient.prototype.leaveRoomChain = function (roomId, includeFuture = true) {
+    const upgradeHistory = this.getRoomUpgradeHistory(roomId);
+
+    let eligibleToLeave = upgradeHistory;
+    if (!includeFuture) {
+        eligibleToLeave = [];
+        for (const room of upgradeHistory) {
+            eligibleToLeave.push(room);
+            if (room.roomId === roomId) {
+                break;
+            }
+        }
+    }
+
+    const populationResults = {}; // {roomId: Error}
+    const promises = [];
+
+    const doLeave = roomId => {
+        return this.leave(roomId).then(() => {
+            populationResults[roomId] = null;
+        }).catch(err => {
+            populationResults[roomId] = err;
+            return null; // suppress error
+        });
+    };
+
+    for (const room of eligibleToLeave) {
+        promises.push(doLeave(room.roomId));
+    }
+
+    return _bluebird2.default.all(promises).then(() => populationResults);
 };
 
 /**
  * @param {string} roomId
  * @param {string} userId
  * @param {string} reason Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.ban = function(roomId, userId, reason, callback) {
-    return _membershipChange(this, roomId, userId, "ban", reason,
-        callback);
+MatrixClient.prototype.ban = function (roomId, userId, reason, callback) {
+    return _membershipChange(this, roomId, userId, "ban", reason, callback);
 };
 
 /**
  * @param {string} roomId
  * @param {boolean} deleteRoom True to delete the room from the store on success.
  * Default: true.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.forget = function(roomId, deleteRoom, callback) {
+MatrixClient.prototype.forget = function (roomId, deleteRoom, callback) {
     if (deleteRoom === undefined) {
         deleteRoom = true;
     }
-    var promise = _membershipChange(this, roomId, undefined, "forget", undefined,
-        callback);
+    const promise = _membershipChange(this, roomId, undefined, "forget", undefined, callback);
     if (!deleteRoom) {
         return promise;
     }
-    var self = this;
-    return promise.then(function(response) {
+    const self = this;
+    return promise.then(function (response) {
         self.store.removeRoom(roomId);
         self.emit("deleteRoom", roomId);
         return response;
     });
 };
 
 /**
  * @param {string} roomId
  * @param {string} userId
  * @param {module:client.callback} callback Optional.
- * @return {module:client.Promise} Resolves: TODO
+ * @return {module:client.Promise} Resolves: Object (currently empty)
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.unban = function(roomId, userId, callback) {
-    // unbanning = set their state to leave
-    return _setMembershipState(
-        this, roomId, userId, "leave", undefined, callback
-    );
+MatrixClient.prototype.unban = function (roomId, userId, callback) {
+    // unbanning != set their state to leave: this used to be
+    // the case, but was then changed so that leaving was always
+    // a revoking of priviledge, otherwise two people racing to
+    // kick / ban someone could end up banning and then un-banning
+    // them.
+    const path = utils.encodeUri("/rooms/$roomId/unban", {
+        $roomId: roomId
+    });
+    const data = {
+        user_id: userId
+    };
+    return this._http.authedRequest(callback, "POST", path, undefined, data);
 };
 
 /**
  * @param {string} roomId
  * @param {string} userId
  * @param {string} reason Optional.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.kick = function(roomId, userId, reason, callback) {
-    return _setMembershipState(
-        this, roomId, userId, "leave", reason, callback
-    );
+MatrixClient.prototype.kick = function (roomId, userId, reason, callback) {
+    return _setMembershipState(this, roomId, userId, "leave", reason, callback);
 };
 
 /**
  * This is an internal method.
  * @param {MatrixClient} client
  * @param {string} roomId
  * @param {string} userId
  * @param {string} membershipValue
  * @param {string} reason
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-function _setMembershipState(client, roomId, userId, membershipValue, reason,
-                             callback) {
-    if (utils.isFunction(reason)) { callback = reason; reason = undefined; }
-
-    var path = utils.encodeUri(
-        "/rooms/$roomId/state/m.room.member/$userId",
-        { $roomId: roomId, $userId: userId}
-    );
+function _setMembershipState(client, roomId, userId, membershipValue, reason, callback) {
+    if (utils.isFunction(reason)) {
+        callback = reason;reason = undefined;
+    }
+
+    const path = utils.encodeUri("/rooms/$roomId/state/m.room.member/$userId", { $roomId: roomId, $userId: userId });
 
     return client._http.authedRequest(callback, "PUT", path, undefined, {
         membership: membershipValue,
         reason: reason
     });
 }
 
 /**
@@ -1348,133 +2540,188 @@ function _setMembershipState(client, roo
  * @param {string} userId
  * @param {string} membership
  * @param {string} reason
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
 function _membershipChange(client, roomId, userId, membership, reason, callback) {
-    if (utils.isFunction(reason)) { callback = reason; reason = undefined; }
-
-    var path = utils.encodeUri("/rooms/$room_id/$membership", {
+    if (utils.isFunction(reason)) {
+        callback = reason;reason = undefined;
+    }
+
+    const path = utils.encodeUri("/rooms/$room_id/$membership", {
         $room_id: roomId,
         $membership: membership
     });
-    return client._http.authedRequest(
-        callback, "POST", path, undefined, {
-            user_id: userId,  // may be undefined e.g. on leave
-            reason: reason
-        }
-    );
+    return client._http.authedRequest(callback, "POST", path, undefined, {
+        user_id: userId, // may be undefined e.g. on leave
+        reason: reason
+    });
 }
 
 /**
  * Obtain a dict of actions which should be performed for this event according
  * to the push rules for this user.  Caches the dict on the event.
  * @param {MatrixEvent} event The event to get push actions for.
  * @return {module:pushprocessor~PushAction} A dict of actions to perform.
  */
-MatrixClient.prototype.getPushActionsForEvent = function(event) {
+MatrixClient.prototype.getPushActionsForEvent = function (event) {
     if (!event.getPushActions()) {
-        var pushProcessor = new PushProcessor(this);
-        event.setPushActions(pushProcessor.actionsForEvent(event));
+        event.setPushActions(this._pushProcessor.actionsForEvent(event));
     }
     return event.getPushActions();
 };
 
 // Profile operations
 // ==================
 
 /**
  * @param {string} info The kind of info to set (e.g. 'avatar_url')
  * @param {Object} data The JSON object to set.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setProfileInfo = function(info, data, callback) {
-    var path = utils.encodeUri("/profile/$userId/$info", {
+MatrixClient.prototype.setProfileInfo = function (info, data, callback) {
+    const path = utils.encodeUri("/profile/$userId/$info", {
         $userId: this.credentials.userId,
         $info: info
     });
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, data
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, data);
 };
 
 /**
  * @param {string} name
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setDisplayName = function(name, callback) {
-    return this.setProfileInfo(
-        "displayname", { displayname: name }, callback
-    );
+MatrixClient.prototype.setDisplayName = function (name, callback) {
+    return this.setProfileInfo("displayname", { displayname: name }, callback);
 };
 
 /**
  * @param {string} url
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setAvatarUrl = function(url, callback) {
-    return this.setProfileInfo(
-        "avatar_url", { avatar_url: url }, callback
-    );
+MatrixClient.prototype.setAvatarUrl = function (url, callback) {
+    return this.setProfileInfo("avatar_url", { avatar_url: url }, callback);
 };
 
 /**
  * Turn an MXC URL into an HTTP one. <strong>This method is experimental and
  * may change.</strong>
  * @param {string} mxcUrl The MXC URL
  * @param {Number} width The desired width of the thumbnail.
  * @param {Number} height The desired height of the thumbnail.
  * @param {string} resizeMethod The thumbnail resize method to use, either
  * "crop" or "scale".
  * @param {Boolean} allowDirectLinks If true, return any non-mxc URLs
  * directly. Fetching such URLs will leak information about the user to
  * anyone they share a room with. If false, will return null for such URLs.
  * @return {?string} the avatar URL or null.
  */
-MatrixClient.prototype.mxcUrlToHttp =
-        function(mxcUrl, width, height, resizeMethod, allowDirectLinks) {
-    return contentRepo.getHttpUriForMxc(
-        this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks
-    );
+MatrixClient.prototype.mxcUrlToHttp = function (mxcUrl, width, height, resizeMethod, allowDirectLinks) {
+    return contentRepo.getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks);
+};
+
+/**
+ * Sets a new status message for the user. The message may be null/falsey
+ * to clear the message.
+ * @param {string} newMessage The new message to set.
+ * @return {module:client.Promise} Resolves: to nothing
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype._unstable_setStatusMessage = function (newMessage) {
+    const type = "im.vector.user_status";
+    return _bluebird2.default.all(this.getRooms().map(room => {
+        const isJoined = room.getMyMembership() === "join";
+        const looksLikeDm = room.getInvitedAndJoinedMemberCount() === 2;
+        if (!isJoined || !looksLikeDm) {
+            return _bluebird2.default.resolve();
+        }
+        // Check power level separately as it's a bit more expensive.
+        const maySend = room.currentState.mayClientSendStateEvent(type, this);
+        if (!maySend) {
+            return _bluebird2.default.resolve();
+        }
+        return this.sendStateEvent(room.roomId, type, {
+            status: newMessage
+        }, this.getUserId());
+    }));
 };
 
 /**
  * @param {Object} opts Options to apply
  * @param {string} opts.presence One of "online", "offline" or "unavailable"
  * @param {string} opts.status_msg The status message to attach.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  * @throws If 'presence' isn't a valid presence enum value.
  */
-MatrixClient.prototype.setPresence = function(opts, callback) {
-    var path = utils.encodeUri("/presence/$userId/status", {
+MatrixClient.prototype.setPresence = function (opts, callback) {
+    const path = utils.encodeUri("/presence/$userId/status", {
         $userId: this.credentials.userId
     });
 
     if (typeof opts === "string") {
-      opts = { presence: opts };
+        opts = { presence: opts };
     }
 
-    var validStates = ["offline", "online", "unavailable"];
+    const validStates = ["offline", "online", "unavailable"];
     if (validStates.indexOf(opts.presence) == -1) {
         throw new Error("Bad presence value: " + opts.presence);
     }
-    return this._http.authedRequest(
-        callback, "PUT", path, undefined, opts
-    );
+    return this._http.authedRequest(callback, "PUT", path, undefined, opts);
+};
+
+function _presenceList(callback, client, opts, method) {
+    const path = utils.encodeUri("/presence/list/$userId", {
+        $userId: client.credentials.userId
+    });
+    return client._http.authedRequest(callback, method, path, undefined, opts);
+}
+
+/**
+* Retrieve current user presence list.
+* @param {module:client.callback} callback Optional.
+* @return {module:client.Promise} Resolves: TODO
+* @return {module:http-api.MatrixError} Rejects: with an error response.
+*/
+MatrixClient.prototype.getPresenceList = function (callback) {
+    return _presenceList(callback, this, undefined, "GET");
+};
+
+/**
+* Add users to the current user presence list.
+* @param {module:client.callback} callback Optional.
+* @param {string[]} userIds
+* @return {module:client.Promise} Resolves: TODO
+* @return {module:http-api.MatrixError} Rejects: with an error response.
+*/
+MatrixClient.prototype.inviteToPresenceList = function (callback, userIds) {
+    const opts = { "invite": userIds };
+    return _presenceList(callback, this, opts, "POST");
+};
+
+/**
+* Drop users from the current user presence list.
+* @param {module:client.callback} callback Optional.
+* @param {string[]} userIds
+* @return {module:client.Promise} Resolves: TODO
+* @return {module:http-api.MatrixError} Rejects: with an error response.
+**/
+MatrixClient.prototype.dropFromPresenceList = function (callback, userIds) {
+    const opts = { "drop": userIds };
+    return _presenceList(callback, this, opts, "POST");
 };
 
 /**
  * Retrieve older messages from the given room and put them in the timeline.
  *
  * If this is called multiple times whilst a request is ongoing, the <i>same</i>
  * Promise will be returned. If there was a problem requesting scrollback, there
  * will be a small delay before another request can be made (to prevent tight-looping
@@ -1484,380 +2731,329 @@ MatrixClient.prototype.setPresence = fun
  * @param {Integer} limit Optional. The maximum number of previous events to
  * pull in. Default: 30.
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: Room. If you are at the beginning
  * of the timeline, <code>Room.oldState.paginationToken</code> will be
  * <code>null</code>.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.scrollback = function(room, limit, callback) {
-    if (utils.isFunction(limit)) { callback = limit; limit = undefined; }
+MatrixClient.prototype.scrollback = function (room, limit, callback) {
+    if (utils.isFunction(limit)) {
+        callback = limit;limit = undefined;
+    }
     limit = limit || 30;
-    var timeToWaitMs = 0;
-
-    var info = this._ongoingScrollbacks[room.roomId] || {};
+    let timeToWaitMs = 0;
+
+    let info = this._ongoingScrollbacks[room.roomId] || {};
     if (info.promise) {
         return info.promise;
-    }
-    else if (info.errorTs) {
-        var timeWaitedMs = Date.now() - info.errorTs;
+    } else if (info.errorTs) {
+        const timeWaitedMs = Date.now() - info.errorTs;
         timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0);
     }
 
     if (room.oldState.paginationToken === null) {
-        return q(room); // already at the start.
+        return _bluebird2.default.resolve(room); // already at the start.
     }
     // attempt to grab more events from the store first
-    var numAdded = this.store.scrollback(room, limit).length;
+    const numAdded = this.store.scrollback(room, limit).length;
     if (numAdded === limit) {
         // store contained everything we needed.
-        return q(room);
+        return _bluebird2.default.resolve(room);
     }
     // reduce the required number of events appropriately
     limit = limit - numAdded;
 
-    var path = utils.encodeUri(
-        "/rooms/$roomId/messages", {$roomId: room.roomId}
-    );
-    var params = {
-        from: room.oldState.paginationToken,
-        limit: limit,
-        dir: 'b'
-    };
-    var defer = q.defer();
+    const defer = _bluebird2.default.defer();
     info = {
         promise: defer.promise,
         errorTs: null
     };
-    var self = this;
+    const self = this;
     // wait for a time before doing this request
     // (which may be 0 in order not to special case the code paths)
-    q.delay(timeToWaitMs).then(function() {
-        return self._http.authedRequest(callback, "GET", path, params);
-    }).done(function(res) {
-        var matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self));
+    _bluebird2.default.delay(timeToWaitMs).then(function () {
+        return self._createMessagesRequest(room.roomId, room.oldState.paginationToken, limit, 'b');
+    }).done(function (res) {
+        const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self));
+        if (res.state) {
+            const stateEvents = utils.map(res.state, _PojoToMatrixEventMapper(self));
+            room.currentState.setUnknownStateEvents(stateEvents);
+        }
         room.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline());
         room.oldState.paginationToken = res.end;
         if (res.chunk.length === 0) {
             room.oldState.paginationToken = null;
         }
         self.store.storeEvents(room, matrixEvents, res.end, true);
         self._ongoingScrollbacks[room.roomId] = null;
         _resolve(callback, defer, room);
-    }, function(err) {
+    }, function (err) {
         self._ongoingScrollbacks[room.roomId] = {
             errorTs: Date.now()
         };
         _reject(callback, defer, err);
     });
     this._ongoingScrollbacks[room.roomId] = info;
     return defer.promise;
 };
 
 /**
- * Take an EventContext, and back/forward-fill results.
- *
- * @param {module:models/event-context.EventContext} eventContext  context
- *    object to be updated
- * @param {Object}  opts
- * @param {boolean} opts.backwards  true to fill backwards, false to go forwards
- * @param {boolean} opts.limit      number of events to request
- *
- * @return {module:client.Promise} Resolves: updated EventContext object
- * @return {Error} Rejects: with an error response.
- */
-MatrixClient.prototype.paginateEventContext = function(eventContext, opts) {
-    // TODO: we should implement a backoff (as per scrollback()) to deal more
-    // nicely with HTTP errors.
-    opts = opts || {};
-    var backwards = opts.backwards || false;
-
-    var token = eventContext.getPaginateToken(backwards);
-    if (!token) {
-        // no more results.
-        return q.reject(new Error("No paginate token"));
-    }
-
-    var dir = backwards ? 'b' : 'f';
-    var pendingRequest = eventContext._paginateRequests[dir];
-
-    if (pendingRequest) {
-        // already a request in progress - return the existing promise
-        return pendingRequest;
-    }
-
-    var path = utils.encodeUri(
-        "/rooms/$roomId/messages", {$roomId: eventContext.getEvent().getRoomId()}
-    );
-    var params = {
-        from: token,
-        limit: ('limit' in opts) ? opts.limit : 30,
-        dir: dir
-    };
-
-    var self = this;
-    var promise =
-        self._http.authedRequest(undefined, "GET", path, params
-    ).then(function(res) {
-        var token = res.end;
-        if (res.chunk.length === 0) {
-            token = null;
-        } else {
-            var matrixEvents = utils.map(res.chunk, self.getEventMapper());
-            if (backwards) {
-                // eventContext expects the events in timeline order, but
-                // back-pagination returns them in reverse order.
-                matrixEvents.reverse();
-            }
-            eventContext.addEvents(matrixEvents, backwards);
-        }
-        eventContext.setPaginateToken(token, backwards);
-        return eventContext;
-    }).finally(function() {
-        eventContext._paginateRequests[dir] = null;
-    });
-    eventContext._paginateRequests[dir] = promise;
-
-    return promise;
-};
-
-/**
  * Get an EventTimeline for the given event
  *
  * <p>If the EventTimelineSet object already has the given event in its store, the
  * corresponding timeline will be returned. Otherwise, a /context request is
  * made, and used to construct an EventTimeline.
  *
  * @param {EventTimelineSet} timelineSet  The timelineSet to look for the event in
  * @param {string} eventId  The ID of the event to look for
  *
  * @return {module:client.Promise} Resolves:
  *    {@link module:models/event-timeline~EventTimeline} including the given
  *    event
  */
-MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
+MatrixClient.prototype.getEventTimeline = function (timelineSet, eventId) {
     // don't allow any timeline support unless it's been enabled.
     if (!this.timelineSupport) {
-        throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
-                    " parameter to true when creating MatrixClient to enable" +
-                    " it.");
+        throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable" + " it.");
     }
 
     if (timelineSet.getTimelineForEvent(eventId)) {
-        return q(timelineSet.getTimelineForEvent(eventId));
+        return _bluebird2.default.resolve(timelineSet.getTimelineForEvent(eventId));
     }
 
-    var path = utils.encodeUri(
-        "/rooms/$roomId/context/$eventId", {
-            $roomId: timelineSet.room.roomId,
-            $eventId: eventId,
-        }
-    );
+    const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
+        $roomId: timelineSet.room.roomId,
+        $eventId: eventId
+    });
+
+    let params = undefined;
+    if (this._clientOpts.lazyLoadMembers) {
+        params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
+    }
 
     // TODO: we should implement a backoff (as per scrollback()) to deal more
     // nicely with HTTP errors.
-    var self = this;
-    var promise =
-        self._http.authedRequest(undefined, "GET", path
-    ).then(function(res) {
+    const self = this;
+    const promise = self._http.authedRequest(undefined, "GET", path, params).then(function (res) {
         if (!res.event) {
             throw new Error("'event' not in '/context' result - homeserver too old?");
         }
 
         // by the time the request completes, the event might have ended up in
         // the timeline.
         if (timelineSet.getTimelineForEvent(eventId)) {
             return timelineSet.getTimelineForEvent(eventId);
         }
 
         // we start with the last event, since that's the point at which we
         // have known state.
         // events_after is already backwards; events_before is forwards.
         res.events_after.reverse();
-        var events = res.events_after
-            .concat([res.event])
-            .concat(res.events_before);
-        var matrixEvents = utils.map(events, self.getEventMapper());
-
-        var timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId());
+        const events = res.events_after.concat([res.event]).concat(res.events_before);
+        const matrixEvents = utils.map(events, self.getEventMapper());
+
+        let timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId());
         if (!timeline) {
             timeline = timelineSet.addTimeline();
-            timeline.initialiseState(utils.map(res.state,
-                                               self.getEventMapper()));
+            timeline.initialiseState(utils.map(res.state, self.getEventMapper()));
             timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
+        } else {
+            const stateEvents = utils.map(res.state, self.getEventMapper());
+            timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents);
         }
         timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start);
 
         // there is no guarantee that the event ended up in "timeline" (we
         // might have switched to a neighbouring timeline) - so check the
         // room's index again. On the other hand, there's no guarantee the
         // event ended up anywhere, if it was later redacted, so we just
         // return the timeline we first thought of.
-        var tl = timelineSet.getTimelineForEvent(eventId) || timeline;
+        const tl = timelineSet.getTimelineForEvent(eventId) || timeline;
         return tl;
     });
     return promise;
 };
 
+/**
+ * Makes a request to /messages with the appropriate lazy loading filter set.
+ * XXX: if we do get rid of scrollback (as it's not used at the moment),
+ * we could inline this method again in paginateEventTimeline as that would
+ * then be the only call-site
+ * @param {string} roomId
+ * @param {string} fromToken
+ * @param {number} limit the maximum amount of events the retrieve
+ * @param {string} dir 'f' or 'b'
+ * @param {Filter} timelineFilter the timeline filter to pass
+ * @return {Promise}
+ */
+MatrixClient.prototype._createMessagesRequest = function (roomId, fromToken, limit, dir, timelineFilter = undefined) {
+    const path = utils.encodeUri("/rooms/$roomId/messages", { $roomId: roomId });
+    if (limit === undefined) {
+        limit = 30;
+    }
+    const params = {
+        from: fromToken,
+        limit: limit,
+        dir: dir
+    };
+
+    let filter = null;
+    if (this._clientOpts.lazyLoadMembers) {
+        // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
+        // so the timelineFilter doesn't get written into it below
+        filter = Object.assign({}, Filter.LAZY_LOADING_MESSAGES_FILTER);
+    }
+    if (timelineFilter) {
+        // XXX: it's horrific that /messages' filter parameter doesn't match
+        // /sync's one - see https://matrix.org/jira/browse/SPEC-451
+        filter = filter || {};
+        Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent());
+    }
+    if (filter) {
+        params.filter = JSON.stringify(filter);
+    }
+    return this._http.authedRequest(undefined, "GET", path, params);
+};
 
 /**
  * Take an EventTimeline, and back/forward-fill results.
  *
  * @param {module:models/event-timeline~EventTimeline} eventTimeline timeline
  *    object to be updated
  * @param {Object}   [opts]
  * @param {bool}     [opts.backwards = false]  true to fill backwards,
  *    false to go forwards
  * @param {number}   [opts.limit = 30]         number of events to request
  *
  * @return {module:client.Promise} Resolves to a boolean: false if there are no
  *    events and we reached either end of the timeline; else true.
  */
-MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
-    var isNotifTimeline = (eventTimeline.getTimelineSet() === this._notifTimelineSet);
+MatrixClient.prototype.paginateEventTimeline = function (eventTimeline, opts) {
+    const isNotifTimeline = eventTimeline.getTimelineSet() === this._notifTimelineSet;
 
     // TODO: we should implement a backoff (as per scrollback()) to deal more
     // nicely with HTTP errors.
     opts = opts || {};
-    var backwards = opts.backwards || false;
+    const backwards = opts.backwards || false;
 
     if (isNotifTimeline) {
         if (!backwards) {
             throw new Error("paginateNotifTimeline can only paginate backwards");
         }
     }
 
-    var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
-
-    var token = eventTimeline.getPaginationToken(dir);
+    const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
+
+    const token = eventTimeline.getPaginationToken(dir);
     if (!token) {
         // no token - no results.
-        return q(false);
+        return _bluebird2.default.resolve(false);
     }
 
-    var pendingRequest = eventTimeline._paginationRequests[dir];
+    const pendingRequest = eventTimeline._paginationRequests[dir];
 
     if (pendingRequest) {
         // already a request in progress - return the existing promise
         return pendingRequest;
     }
 
-    var path, params, promise;
-    var self = this;
+    let path, params, promise;
+    const self = this;
 
     if (isNotifTimeline) {
         path = "/notifications";
         params = {
-            limit: ('limit' in opts) ? opts.limit : 30,
-            only: 'highlight',
+            limit: 'limit' in opts ? opts.limit : 30,
+            only: 'highlight'
         };
 
         if (token && token !== "end") {
             params.from = token;
         }
 
-        promise =
-            this._http.authedRequestWithPrefix(undefined, "GET", path, params,
-                undefined, httpApi.PREFIX_UNSTABLE
-        ).then(function(res) {
-            var token = res.next_token;
-            var matrixEvents = [];
-
-            for (var i = 0; i < res.notifications.length; i++) {
-                var notification = res.notifications[i];
-                var event = self.getEventMapper()(notification.event);
-                event.setPushActions(
-                    PushProcessor.actionListToActionsObject(notification.actions)
-                );
+        promise = this._http.authedRequest(undefined, "GET", path, params, undefined).then(function (res) {
+            const token = res.next_token;
+            const matrixEvents = [];
+
+            for (let i = 0; i < res.notifications.length; i++) {
+                const notification = res.notifications[i];
+                const event = self.getEventMapper()(notification.event);
+                event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions));
                 event.event.room_id = notification.room_id; // XXX: gutwrenching
                 matrixEvents[i] = event;
             }
 
-            eventTimeline.getTimelineSet()
-                .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+            eventTimeline.getTimelineSet().addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
 
             // if we've hit the end of the timeline, we need to stop trying to
             // paginate. We need to keep the 'forwards' token though, to make sure
             // we can recover from gappy syncs.
             if (backwards && !res.next_token) {
                 eventTimeline.setPaginationToken(null, dir);
             }
             return res.next_token ? true : false;
-        }).finally(function() {
+        }).finally(function () {
             eventTimeline._paginationRequests[dir] = null;
         });
         eventTimeline._paginationRequests[dir] = promise;
-    }
-    else {
-        var room = this.getRoom(eventTimeline.getRoomId());
+    } else {
+        const room = this.getRoom(eventTimeline.getRoomId());
         if (!room) {
             throw new Error("Unknown room " + eventTimeline.getRoomId());
         }
 
-        path = utils.encodeUri(
-            "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()}
-        );
-        params = {
-            from: token,
-            limit: ('limit' in opts) ? opts.limit : 30,
-            dir: dir
-        };
-
-        var filter = eventTimeline.getFilter();
-        if (filter) {
-            // XXX: it's horrific that /messages' filter parameter doesn't match
-            // /sync's one - see https://matrix.org/jira/browse/SPEC-451
-            params.filter = JSON.stringify(filter.getRoomTimelineFilterComponent());
-        }
-
-        promise =
-            this._http.authedRequest(undefined, "GET", path, params
-        ).then(function(res) {
-            var token = res.end;
-            var matrixEvents = utils.map(res.chunk, self.getEventMapper());
-            eventTimeline.getTimelineSet()
-                .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+        promise = this._createMessagesRequest(eventTimeline.getRoomId(), token, opts.limit, dir, eventTimeline.getFilter());
+        promise.then(function (res) {
+            if (res.state) {
+                const roomState = eventTimeline.getState(dir);
+                const stateEvents = utils.map(res.state, self.getEventMapper());
+                roomState.setUnknownStateEvents(stateEvents);
+            }
+            const token = res.end;
+            const matrixEvents = utils.map(res.chunk, self.getEventMapper());
+            eventTimeline.getTimelineSet().addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
 
             // if we've hit the end of the timeline, we need to stop trying to
             // paginate. We need to keep the 'forwards' token though, to make sure
             // we can recover from gappy syncs.
             if (backwards && res.end == res.start) {
                 eventTimeline.setPaginationToken(null, dir);
             }
             return res.end != res.start;
-        }).finally(function() {
+        }).finally(function () {
             eventTimeline._paginationRequests[dir] = null;
         });
         eventTimeline._paginationRequests[dir] = promise;
     }
 
     return promise;
 };
 
 /**
  * Reset the notifTimelineSet entirely, paginating in some historical notifs as
  * a starting point for subsequent pagination.
  */
-MatrixClient.prototype.resetNotifTimelineSet = function() {
+MatrixClient.prototype.resetNotifTimelineSet = function () {
     if (!this._notifTimelineSet) {
         return;
     }
 
     // FIXME: This thing is a total hack, and results in duplicate events being
     // added to the timeline both from /sync and /notifications, and lots of
     // slow and wasteful processing and pagination.  The correct solution is to
     // extend /messages or /search or something to filter on notifications.
 
     // use the fictitious token 'end'. in practice we would ideally give it
     // the oldest backwards pagination token from /sync, but /sync doesn't
     // know about /notifications, so we have no choice but to start paginating
     // from the current point in time.  This may well overlap with historical
     // notifs which are then inserted into the timeline by /sync responses.
-    this._notifTimelineSet.resetLiveTimeline('end', true);
+    this._notifTimelineSet.resetLiveTimeline('end', null);
 
     // we could try to paginate a single event at this point in order to get
     // a more valid pagination token, but it just ends up with an out of order
     // timeline. given what a mess this is and given we're going to have duplicate
     // events anyway, just leave it with the dummy token for now.
     /*
     this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), {
         backwards: true,
@@ -1868,28 +3064,28 @@ MatrixClient.prototype.resetNotifTimelin
 
 /**
  * Peek into a room and receive updates about the room. This only works if the
  * history visibility for the room is world_readable.
  * @param {String} roomId The room to attempt to peek into.
  * @return {module:client.Promise} Resolves: Room object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.peekInRoom = function(roomId) {
+MatrixClient.prototype.peekInRoom = function (roomId) {
     if (this._peekSync) {
         this._peekSync.stopPeeking();
     }
     this._peekSync = new SyncApi(this, this._clientOpts);
     return this._peekSync.peek(roomId);
 };
 
 /**
  * Stop any ongoing room peeking.
  */
-MatrixClient.prototype.stopPeeking = function() {
+MatrixClient.prototype.stopPeeking = function () {
     if (this._peekSync) {
         this._peekSync.stopPeeking();
         this._peekSync = null;
     }
 };
 
 /**
  * Set r/w flags for guest access in a room.
@@ -1899,88 +3095,129 @@ MatrixClient.prototype.stopPeeking = fun
  * implicitly gives guests write access. If false or not given, guests are
  * explicitly forbidden from joining the room.
  * @param {boolean} opts.allowRead True to set history visibility to
  * be world_readable. This gives guests read access *from this point forward*.
  * If false or not given, history visibility is not modified.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setGuestAccess = function(roomId, opts) {
-    var writePromise = this.sendStateEvent(roomId, "m.room.guest_access", {
+MatrixClient.prototype.setGuestAccess = function (roomId, opts) {
+    const writePromise = this.sendStateEvent(roomId, "m.room.guest_access", {
         guest_access: opts.allowJoin ? "can_join" : "forbidden"
     });
 
-    var readPromise = q();
+    let readPromise = _bluebird2.default.resolve();
     if (opts.allowRead) {
         readPromise = this.sendStateEvent(roomId, "m.room.history_visibility", {
             history_visibility: "world_readable"
         });
     }
 
-    return q.all(readPromise, writePromise);
+    return _bluebird2.default.all([readPromise, writePromise]);
 };
 
 // Registration/Login operations
 // =============================
 
 /**
  * Requests an email verification token for the purposes of registration.
- * This API proxies the Identity Server /validate/email/requestToken API,
- * adding registration-specific behaviour. Specifically, if an account with
- * the given email address already exists, it will either send an email
- * to the address informing them of this or return M_THREEPID_IN_USE
- * (which one is up to the Home Server).
- *
- * requestEmailToken calls the equivalent API directly on the ID server,
- * therefore bypassing the registration-specific logic.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
  *
  * Parameters and return value are as for requestEmailToken
 
  * @param {string} email As requestEmailToken
  * @param {string} clientSecret As requestEmailToken
  * @param {number} sendAttempt As requestEmailToken
  * @param {string} nextLink As requestEmailToken
- * @param {module:client.callback} callback Optional. As requestEmailToken
  * @return {module:client.Promise} Resolves: As requestEmailToken
  */
-MatrixClient.prototype.requestRegisterEmailToken = function(email, clientSecret,
-                                                    sendAttempt, nextLink, callback) {
-    return this._requestTokenFromEndpoint(
-        "/register/email/requestToken",
-        email, clientSecret, sendAttempt, nextLink, callback
-    );
+MatrixClient.prototype.requestRegisterEmailToken = function (email, clientSecret, sendAttempt, nextLink) {
+    return this._requestTokenFromEndpoint("/register/email/requestToken", {
+        email: email,
+        client_secret: clientSecret,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    });
+};
+
+/**
+ * Requests a text message verification token for the purposes of registration.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ *
+ * @param {string} phoneCountry The ISO 3166-1 alpha-2 code for the country in which
+ *    phoneNumber should be parsed relative to.
+ * @param {string} phoneNumber The phone number, in national or international format
+ * @param {string} clientSecret As requestEmailToken
+ * @param {number} sendAttempt As requestEmailToken
+ * @param {string} nextLink As requestEmailToken
+ * @return {module:client.Promise} Resolves: As requestEmailToken
+ */
+MatrixClient.prototype.requestRegisterMsisdnToken = function (phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) {
+    return this._requestTokenFromEndpoint("/register/msisdn/requestToken", {
+        country: phoneCountry,
+        phone_number: phoneNumber,
+        client_secret: clientSecret,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    });
 };
 
 /**
  * Requests an email verification token for the purposes of adding a
  * third party identifier to an account.
- * This API proxies the Identity Server /validate/email/requestToken API,
- * adding specific behaviour for the addition of email addresses to an
- * account. Specifically, if an account with
- * the given email address already exists, it will either send an email
- * to the address informing them of this or return M_THREEPID_IN_USE
- * (which one is up to the Home Server).
- *
- * requestEmailToken calls the equivalent API directly on the ID server,
- * therefore bypassing the email addition specific logic.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ * If an account with the given email address already exists and is
+ * associated with an account other than the one the user is authed as,
+ * it will either send an email to the address informing them of this
+ * or return M_THREEPID_IN_USE (which one is up to the Home Server).
  *
  * @param {string} email As requestEmailToken
  * @param {string} clientSecret As requestEmailToken
  * @param {number} sendAttempt As requestEmailToken
  * @param {string} nextLink As requestEmailToken
- * @param {module:client.callback} callback Optional. As requestEmailToken
  * @return {module:client.Promise} Resolves: As requestEmailToken
  */
-MatrixClient.prototype.requestAdd3pidEmailToken = function(email, clientSecret,
-                                                    sendAttempt, nextLink, callback) {
-    return this._requestTokenFromEndpoint(
-        "/account/3pid/email/requestToken",
-        email, clientSecret, sendAttempt, nextLink, callback
-    );
+MatrixClient.prototype.requestAdd3pidEmailToken = function (email, clientSecret, sendAttempt, nextLink) {
+    return this._requestTokenFromEndpoint("/account/3pid/email/requestToken", {
+        email: email,
+        client_secret: clientSecret,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    });
+};
+
+/**
+ * Requests a text message verification token for the purposes of adding a
+ * third party identifier to an account.
+ * This API proxies the Identity Server /validate/email/requestToken API,
+ * adding specific behaviour for the addition of phone numbers to an
+ * account, as requestAdd3pidEmailToken.
+ *
+ * @param {string} phoneCountry As requestRegisterMsisdnToken
+ * @param {string} phoneNumber As requestRegisterMsisdnToken
+ * @param {string} clientSecret As requestEmailToken
+ * @param {number} sendAttempt As requestEmailToken
+ * @param {string} nextLink As requestEmailToken
+ * @return {module:client.Promise} Resolves: As requestEmailToken
+ */
+MatrixClient.prototype.requestAdd3pidMsisdnToken = function (phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) {
+    return this._requestTokenFromEndpoint("/account/3pid/msisdn/requestToken", {
+        country: phoneCountry,
+        phone_number: phoneNumber,
+        client_secret: clientSecret,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    });
 };
 
 /**
  * Requests an email verification token for the purposes of resetting
  * the password on an account.
  * This API proxies the Identity Server /validate/email/requestToken API,
  * adding specific behaviour for the password resetting. Specifically,
  * if no account with the given email address exists, it may either
@@ -1992,156 +3229,171 @@ MatrixClient.prototype.requestAdd3pidEma
  *
  * @param {string} email As requestEmailToken
  * @param {string} clientSecret As requestEmailToken
  * @param {number} sendAttempt As requestEmailToken
  * @param {string} nextLink As requestEmailToken
  * @param {module:client.callback} callback Optional. As requestEmailToken
  * @return {module:client.Promise} Resolves: As requestEmailToken
  */
-MatrixClient.prototype.requestPasswordEmailToken = function(email, clientSecret,
-                                                    sendAttempt, nextLink, callback) {
-    return this._requestTokenFromEndpoint(
-        "/account/password/email/requestToken",
-        email, clientSecret, sendAttempt, nextLink, callback
-    );
+MatrixClient.prototype.requestPasswordEmailToken = function (email, clientSecret, sendAttempt, nextLink) {
+    return this._requestTokenFromEndpoint("/account/password/email/requestToken", {
+        email: email,
+        client_secret: clientSecret,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    });
+};
+
+/**
+ * Requests a text message verification token for the purposes of resetting
+ * the password on an account.
+ * This API proxies the Identity Server /validate/email/requestToken API,
+ * adding specific behaviour for the password resetting, as requestPasswordEmailToken.
+ *
+ * @param {string} phoneCountry As requestRegisterMsisdnToken
+ * @param {string} phoneNumber As requestRegisterMsisdnToken
+ * @param {string} clientSecret As requestEmailToken
+ * @param {number} sendAttempt As requestEmailToken
+ * @param {string} nextLink As requestEmailToken
+ * @return {module:client.Promise} Resolves: As requestEmailToken
+ */
+MatrixClient.prototype.requestPasswordMsisdnToken = function (phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink) {
+    return this._requestTokenFromEndpoint("/account/password/msisdn/requestToken", {
+        country: phoneCountry,
+        phone_number: phoneNumber,
+        client_secret: clientSecret,
+        send_attempt: sendAttempt,
+        next_link: nextLink
+    });
 };
 
 /**
  * Internal utility function for requesting validation tokens from usage-specific
  * requestToken endpoints.
  *
  * @param {string} endpoint The endpoint to send the request to
- * @param {string} email As requestEmailToken
- * @param {string} clientSecret As requestEmailToken
- * @param {number} sendAttempt As requestEmailToken
- * @param {string} nextLink As requestEmailToken
- * @param {module:client.callback} callback Optional. As requestEmailToken
+ * @param {object} params Parameters for the POST request
  * @return {module:client.Promise} Resolves: As requestEmailToken
  */
-MatrixClient.prototype._requestTokenFromEndpoint = function(endpoint,
-                                                    email, clientSecret,
-                                                    sendAttempt, nextLink, callback) {
-    var id_server_url = url.parse(this.idBaseUrl);
-    if (id_server_url.host === null) {
-        throw new Error("Invalid ID server URL: " + this.idBaseUrl);
+MatrixClient.prototype._requestTokenFromEndpoint = async function (endpoint, params) {
+    const postParams = Object.assign({}, params);
+
+    // If the HS supports separate add and bind, then requestToken endpoints
+    // don't need an IS as they are all validated by the HS directly.
+    if (!(await this.doesServerSupportSeparateAddAndBind()) && this.idBaseUrl) {
+        const idServerUrl = url.parse(this.idBaseUrl);
+        if (!idServerUrl.host) {
+            throw new Error("Invalid ID server URL: " + this.idBaseUrl);
+        }
+        postParams.id_server = idServerUrl.host;
+
+        if (this.identityServer && this.identityServer.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
+            const identityAccessToken = await this.identityServer.getAccessToken();
+            if (identityAccessToken) {
+                postParams.id_access_token = identityAccessToken;
+            }
+        }
     }
 
-    var params = {
-        client_secret: clientSecret,
-        email: email,
-        send_attempt: sendAttempt,
-        next_link: nextLink,
-        id_server: id_server_url.host,
-    };
-    return this._http.request(
-        callback, "POST", endpoint, undefined,
-        params
-    );
+    return this._http.request(undefined, "POST", endpoint, undefined, postParams);
 };
 
-
 // Push operations
 // ===============
 
 /**
  * Get the room-kind push rule associated with a room.
  * @param {string} scope "global" or device-specific.
  * @param {string} roomId the id of the room.
  * @return {object} the rule or undefined.
  */
-MatrixClient.prototype.getRoomPushRule = function(scope, roomId) {
+MatrixClient.prototype.getRoomPushRule = function (scope, roomId) {
     // There can be only room-kind push rule per room
     // and its id is the room id.
     if (this.pushRules) {
-        for (var i = 0; i < this.pushRules[scope].room.length; i++) {
-            var rule = this.pushRules[scope].room[i];
+        for (let i = 0; i < this.pushRules[scope].room.length; i++) {
+            const rule = this.pushRules[scope].room[i];
             if (rule.rule_id === roomId) {
                 return rule;
             }
         }
-    }
-    else {
-        throw new Error(
-            "SyncApi.sync() must be done before accessing to push rules."
-        );
+    } else {
+        throw new Error("SyncApi.sync() must be done before accessing to push rules.");
     }
 };
 
 /**
  * Set a room-kind muting push rule in a room.
  * The operation also updates MatrixClient.pushRules at the end.
  * @param {string} scope "global" or device-specific.
  * @param {string} roomId the id of the room.
  * @param {string} mute the mute state.
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.setRoomMutePushRule = function(scope, roomId, mute) {
-    var self = this;
-    var deferred, hasDontNotifyRule;
+MatrixClient.prototype.setRoomMutePushRule = function (scope, roomId, mute) {
+    const self = this;
+    let deferred, hasDontNotifyRule;
 
     // Get the existing room-kind push rule if any
-    var roomPushRule = this.getRoomPushRule(scope, roomId);
+    const roomPushRule = this.getRoomPushRule(scope, roomId);
     if (roomPushRule) {
         if (0 <= roomPushRule.actions.indexOf("dont_notify")) {
             hasDontNotifyRule = true;
         }
     }
 
     if (!mute) {
         // Remove the rule only if it is a muting rule
         if (hasDontNotifyRule) {
             deferred = this.deletePushRule(scope, "room", roomPushRule.rule_id);
         }
-    }
-    else {
+    } else {
         if (!roomPushRule) {
             deferred = this.addPushRule(scope, "room", roomId, {
                 actions: ["dont_notify"]
             });
-        }
-        else if (!hasDontNotifyRule) {
+        } else if (!hasDontNotifyRule) {
             // Remove the existing one before setting the mute push rule
             // This is a workaround to SYN-590 (Push rule update fails)
-            deferred = q.defer();
-            this.deletePushRule(scope, "room", roomPushRule.rule_id)
-            .done(function() {
+            deferred = _bluebird2.default.defer();
+            this.deletePushRule(scope, "room", roomPushRule.rule_id).done(function () {
                 self.addPushRule(scope, "room", roomId, {
                     actions: ["dont_notify"]
-                }).done(function() {
+                }).done(function () {
                     deferred.resolve();
-                }, function(err) {
+                }, function (err) {
                     deferred.reject(err);
                 });
-            }, function(err) {
+            }, function (err) {
                 deferred.reject(err);
             });
 
             deferred = deferred.promise;
         }
     }
 
     if (deferred) {
         // Update this.pushRules when the operation completes
-        var ruleRefreshDeferred = q.defer();
-        deferred.done(function() {
-            self.getPushRules().done(function(result) {
+        const ruleRefreshDeferred = _bluebird2.default.defer();
+        deferred.done(function () {
+            self.getPushRules().done(function (result) {
                 self.pushRules = result;
                 ruleRefreshDeferred.resolve();
-            }, function(err) {
+            }, function (err) {
                 ruleRefreshDeferred.reject(err);
             });
-        }, function(err) {
+        }, function (err) {
             // Update it even if the previous operation fails. This can help the
             // app to recover when push settings has been modifed from another client
-            self.getPushRules().done(function(result) {
+            self.getPushRules().done(function (result) {
                 self.pushRules = result;
                 ruleRefreshDeferred.reject(err);
-            }, function(err2) {
+            }, function (err2) {
                 ruleRefreshDeferred.reject(err);
             });
         });
         return ruleRefreshDeferred.promise;
     }
 };
 
 // Search
@@ -2152,24 +3404,29 @@ MatrixClient.prototype.setRoomMutePushRu
  * @param {Object} opts Options for the search.
  * @param {string} opts.query The text to query.
  * @param {string=} opts.keys The keys to search on. Defaults to all keys. One
  * of "content.body", "content.name", "content.topic".
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.searchMessageText = function(opts, callback) {
+MatrixClient.prototype.searchMessageText = function (opts, callback) {
+    const roomEvents = {
+        search_term: opts.query
+    };
+
+    if ('keys' in opts) {
+        roomEvents.keys = opts.keys;
+    }
+
     return this.search({
         body: {
             search_categories: {
-                room_events: {
-                    keys: opts.keys,
-                    search_term: opts.query
-                }
+                room_events: roomEvents
             }
         }
     }, callback);
 };
 
 /**
  * Perform a server-side search for room events.
  *
@@ -2185,590 +3442,789 @@ MatrixClient.prototype.searchMessageText
  * Each entry in the results list is a {module:models/search-result.SearchResult}.
  *
  * @param {Object} opts
  * @param {string} opts.term     the term to search for
  * @param {Object} opts.filter   a JSON filter object to pass in the request
  * @return {module:client.Promise} Resolves: result object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.searchRoomEvents = function(opts) {
+MatrixClient.prototype.searchRoomEvents = function (opts) {
     // TODO: support groups
 
-    var body = {
+    const body = {
         search_categories: {
             room_events: {
                 search_term: opts.term,
                 filter: opts.filter,
                 order_by: "recent",
                 event_context: {
                     before_limit: 1,
                     after_limit: 1,
-                    include_profile: true,
+                    include_profile: true
                 }
             }
         }
     };
 
-    var searchResults = {
+    const searchResults = {
         _query: body,
         results: [],
-        highlights: [],
+        highlights: []
     };
 
-    return this.search({body: body}).then(
-        this._processRoomEventsSearch.bind(this, searchResults)
-    );
+    return this.search({ body: body }).then(this._processRoomEventsSearch.bind(this, searchResults));
 };
 
 /**
  * Take a result from an earlier searchRoomEvents call, and backfill results.
  *
  * @param  {object} searchResults  the results object to be updated
  * @return {module:client.Promise} Resolves: updated result object
  * @return {Error} Rejects: with an error response.
  */
-MatrixClient.prototype.backPaginateRoomEventsSearch = function(searchResults) {
+MatrixClient.prototype.backPaginateRoomEventsSearch = function (searchResults) {
     // TODO: we should implement a backoff (as per scrollback()) to deal more
     // nicely with HTTP errors.
 
     if (!searchResults.next_batch) {
-        return q.reject(new Error("Cannot backpaginate event search any further"));
+        return _bluebird2.default.reject(new Error("Cannot backpaginate event search any further"));
     }
 
     if (searchResults.pendingRequest) {
         // already a request in progress - return the existing promise
         return searchResults.pendingRequest;
     }
 
-    var searchOpts = {
+    const searchOpts = {
         body: searchResults._query,
-        next_batch: searchResults.next_batch,
+        next_batch: searchResults.next_batch
     };
 
-    var promise = this.search(searchOpts).then(
-        this._processRoomEventsSearch.bind(this, searchResults)
-    ).finally(function() {
+    const promise = this.search(searchOpts).then(this._processRoomEventsSearch.bind(this, searchResults)).finally(function () {
         searchResults.pendingRequest = null;
     });
     searchResults.pendingRequest = promise;
 
     return promise;
 };
 
 /**
  * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the
  * response from the API call and updates the searchResults
  *
  * @param {Object} searchResults
  * @param {Object} response
  * @return {Object} searchResults
  * @private
  */
-MatrixClient.prototype._processRoomEventsSearch = function(searchResults, response) {
-    var room_events = response.search_categories.room_events;
+MatrixClient.prototype._processRoomEventsSearch = function (searchResults, response) {
+    const room_events = response.search_categories.room_events;
 
     searchResults.count = room_events.count;
     searchResults.next_batch = room_events.next_batch;
 
     // combine the highlight list with our existing list; build an object
     // to avoid O(N^2) fail
-    var highlights = {};
-    room_events.highlights.forEach(function(hl) { highlights[hl] = 1; });
-    searchResults.highlights.forEach(function(hl) { highlights[hl] = 1; });
+    const highlights = {};
+    room_events.highlights.forEach(function (hl) {
+        highlights[hl] = 1;
+    });
+    searchResults.highlights.forEach(function (hl) {
+        highlights[hl] = 1;
+    });
 
     // turn it back into a list.
     searchResults.highlights = Object.keys(highlights);
 
     // append the new results to our existing results
-    for (var i = 0; i < room_events.results.length; i++) {
-        var sr = SearchResult.fromJson(room_events.results[i], this.getEventMapper());
+    for (let i = 0; i < room_events.results.length; i++) {
+        const sr = SearchResult.fromJson(room_events.results[i], this.getEventMapper());
         searchResults.results.push(sr);
     }
     return searchResults;
 };
 
-
 /**
  * Populate the store with rooms the user has left.
  * @return {module:client.Promise} Resolves: TODO - Resolved when the rooms have
  * been added to the data store.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.syncLeftRooms = function() {
+MatrixClient.prototype.syncLeftRooms = function () {
     // Guard against multiple calls whilst ongoing and multiple calls post success
     if (this._syncedLeftRooms) {
-        return q([]); // don't call syncRooms again if it succeeded.
+        return _bluebird2.default.resolve([]); // don't call syncRooms again if it succeeded.
     }
     if (this._syncLeftRoomsPromise) {
         return this._syncLeftRoomsPromise; // return the ongoing request
     }
-    var self = this;
-    var syncApi = new SyncApi(this, this._clientOpts);
+    const self = this;
+    const syncApi = new SyncApi(this, this._clientOpts);
     this._syncLeftRoomsPromise = syncApi.syncLeftRooms();
 
     // cleanup locks
-    this._syncLeftRoomsPromise.then(function(res) {
-        console.log("Marking success of sync left room request");
+    this._syncLeftRoomsPromise.then(function (res) {
+        _logger2.default.log("Marking success of sync left room request");
         self._syncedLeftRooms = true; // flip the bit on success
-    }).finally(function() {
+    }).finally(function () {
         self._syncLeftRoomsPromise = null; // cleanup ongoing request state
     });
 
     return this._syncLeftRoomsPromise;
 };
 
 // Filters
 // =======
 
 /**
  * Create a new filter.
  * @param {Object} content The HTTP body for the request
  * @return {Filter} Resolves to a Filter object.
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.createFilter = function(content) {
-    var self = this;
-    var path = utils.encodeUri("/user/$userId/filter", {
+MatrixClient.prototype.createFilter = function (content) {
+    const self = this;
+    const path = utils.encodeUri("/user/$userId/filter", {
         $userId: this.credentials.userId
     });
-    return this._http.authedRequest(
-        undefined, "POST", path, undefined, content
-    ).then(function(response) {
+    return this._http.authedRequest(undefined, "POST", path, undefined, content).then(function (response) {
         // persist the filter
-        var filter = Filter.fromJson(
-            self.credentials.userId, response.filter_id, content
-        );
+        const filter = Filter.fromJson(self.credentials.userId, response.filter_id, content);
         self.store.storeFilter(filter);
         return filter;
     });
 };
 
 /**
  * Retrieve a filter.
  * @param {string} userId The user ID of the filter owner
  * @param {string} filterId The filter ID to retrieve
  * @param {boolean} allowCached True to allow cached filters to be returned.
  * Default: True.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.getFilter = function(userId, filterId, allowCached) {
+MatrixClient.prototype.getFilter = function (userId, filterId, allowCached) {
     if (allowCached) {
-        var filter = this.store.getFilter(userId, filterId);
+        const filter = this.store.getFilter(userId, filterId);
         if (filter) {
-            return q(filter);
+            return _bluebird2.default.resolve(filter);
         }
     }
 
-    var self = this;
-    var path = utils.encodeUri("/user/$userId/filter/$filterId", {
+    const self = this;
+    const path = utils.encodeUri("/user/$userId/filter/$filterId", {
         $userId: userId,
         $filterId: filterId
     });
 
-    return this._http.authedRequest(
-        undefined, "GET", path, undefined, undefined
-    ).then(function(response) {
+    return this._http.authedRequest(undefined, "GET", path, undefined, undefined).then(function (response) {
         // persist the filter
-        var filter = Filter.fromJson(
-            userId, filterId, response
-        );
+        const filter = Filter.fromJson(userId, filterId, response);
         self.store.storeFilter(filter);
         return filter;
     });
 };
 
 /**
  * @param {string} filterName
  * @param {Filter} filter
  * @return {Promise<String>} Filter ID
  */
-MatrixClient.prototype.getOrCreateFilter = function(filterName, filter) {
-
-    var filterId = this.store.getFilterIdByName(filterName);
-    var promise = q();
-    var self = this;
+MatrixClient.prototype.getOrCreateFilter = function (filterName, filter) {
+    const filterId = this.store.getFilterIdByName(filterName);
+    let promise = _bluebird2.default.resolve();
+    const self = this;
 
     if (filterId) {
         // check that the existing filter matches our expectations
-        promise = self.getFilter(self.credentials.userId,
-                         filterId, true
-        ).then(function(existingFilter) {
-            var oldDef = existingFilter.getDefinition();
-            var newDef = filter.getDefinition();
+        promise = self.getFilter(self.credentials.userId, filterId, true).then(function (existingFilter) {
+            const oldDef = existingFilter.getDefinition();
+            const newDef = filter.getDefinition();
 
             if (utils.deepCompare(oldDef, newDef)) {
                 // super, just use that.
                 // debuglog("Using existing filter ID %s: %s", filterId,
                 //          JSON.stringify(oldDef));
-                return q(filterId);
+                return _bluebird2.default.resolve(filterId);
             }
             // debuglog("Existing filter ID %s: %s; new filter: %s",
             //          filterId, JSON.stringify(oldDef), JSON.stringify(newDef));
             self.store.setFilterIdByName(filterName, undefined);
             return undefined;
-        }, function(error) {
+        }, function (error) {
             // Synapse currently returns the following when the filter cannot be found:
             // {
             //     errcode: "M_UNKNOWN",
             //     name: "M_UNKNOWN",
             //     message: "No row found",
             //     data: Object, httpStatus: 404
             // }
-            if (error.httpStatus === 404 &&
-                (error.errcode === "M_UNKNOWN" || error.errcode === "M_NOT_FOUND")) {
+            if (error.httpStatus === 404 && (error.errcode === "M_UNKNOWN" || error.errcode === "M_NOT_FOUND")) {
                 // Clear existing filterId from localStorage
                 // if it no longer exists on the server
                 self.store.setFilterIdByName(filterName, undefined);
                 // Return a undefined value for existingId further down the promise chain
                 return undefined;
             } else {
                 throw error;
             }
         });
     }
 
-    return promise.then(function(existingId) {
+    return promise.then(function (existingId) {
         if (existingId) {
             return existingId;
         }
 
         // create a new filter
-        return self.createFilter(filter.getDefinition()
-        ).then(function(createdFilter) {
+        return self.createFilter(filter.getDefinition()).then(function (createdFilter) {
             // debuglog("Created new filter ID %s: %s", createdFilter.filterId,
             //          JSON.stringify(createdFilter.getDefinition()));
             self.store.setFilterIdByName(filterName, createdFilter.filterId);
             return createdFilter.filterId;
         });
     });
 };
 
-
 /**
  * Gets a bearer token from the Home Server that the user can
  * present to a third party in order to prove their ownership
  * of the Matrix account they are logged into.
  * @return {module:client.Promise} Resolves: Token object
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.getOpenIdToken = function() {
-    var path = utils.encodeUri("/user/$userId/openid/request_token", {
-        $userId: this.credentials.userId,
+MatrixClient.prototype.getOpenIdToken = function () {
+    const path = utils.encodeUri("/user/$userId/openid/request_token", {
+        $userId: this.credentials.userId
     });
 
-    return this._http.authedRequest(
-        undefined, "POST", path, undefined, {}
-    );
+    return this._http.authedRequest(undefined, "POST", path, undefined, {});
 };
 
-
 // VoIP operations
 // ===============
 
 /**
  * @param {module:client.callback} callback Optional.
  * @return {module:client.Promise} Resolves: TODO
  * @return {module:http-api.MatrixError} Rejects: with an error response.
  */
-MatrixClient.prototype.turnServer = function(callback) {
+MatrixClient.prototype.turnServer = function (callback) {
     return this._http.authedRequest(callback, "GET", "/voip/turnServer");
 };
 
 /**
  * Get the TURN servers for this home server.
  * @return {Array<Object>} The servers or an empty list.
  */
-MatrixClient.prototype.getTurnServers = function() {
+MatrixClient.prototype.getTurnServers = function () {
     return this._turnServers || [];
 };
 
+/**
+ * Set whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
+ * false.
+ *
+ * @param {boolean} allow
+ */
+MatrixClient.prototype.setFallbackICEServerAllowed = function (allow) {
+    this._fallbackICEServerAllowed = allow;
+};
+
+/**
+ * Get whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
+ * false.
+ *
+ * @returns {boolean}
+ */
+MatrixClient.prototype.isFallbackICEServerAllowed = function () {
+    return this._fallbackICEServerAllowed;
+};
+
+// Synapse-specific APIs
+// =====================
+
+/**
+ * Determines if the current user is an administrator of the Synapse homeserver.
+ * Returns false if untrue or the homeserver does not appear to be a Synapse
+ * homeserver. <strong>This function is implementation specific and may change
+ * as a result.</strong>
+ * @return {boolean} true if the user appears to be a Synapse administrator.
+ */
+MatrixClient.prototype.isSynapseAdministrator = function () {
+    return this.whoisSynapseUser(this.getUserId()).then(() => true).catch(() => false);
+};
+
+/**
+ * Performs a whois lookup on a user using Synapse's administrator API.
+ * <strong>This function is implementation specific and may change as a
+ * result.</strong>
+ * @param {string} userId the User ID to look up.
+ * @return {object} the whois response - see Synapse docs for information.
+ */
+MatrixClient.prototype.whoisSynapseUser = function (userId) {
+    const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", { $userId: userId });
+    return this._http.authedRequest(undefined, 'GET', path, undefined, undefined, { prefix: '' });
+};
+
+/**
+ * Deactivates a user using Synapse's administrator API. <strong>This
+ * function is implementation specific and may change as a result.</strong>
+ * @param {string} userId the User ID to deactivate.
+ * @return {object} the deactivate response - see Synapse docs for information.
+ */
+MatrixClient.prototype.deactivateSynapseUser = function (userId) {
+    const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", { $userId: userId });
+    return this._http.authedRequest(undefined, 'POST', path, undefined, undefined, { prefix: '' });
+};
+
 // Higher level APIs
 // =================
 
 // TODO: stuff to handle:
 //   local echo
 //   event dup suppression? - apparently we should still be doing this
 //   tracking current display name / avatar per-message
 //   pagination
 //   re-sending (including persisting pending messages to be sent)
 //   - Need a nice way to callback the app for arbitrary events like
 //     displayname changes
 //   due to ambiguity (or should this be on a chat-specific layer)?
 //   reconnect after connectivity outages
 
 
 /**
- * High level helper method to call initialSync, emit the resulting events,
- * and then start polling the eventStream for new events. To listen for these
+ * High level helper method to begin syncing and poll for new events. To listen for these
  * events, add a listener for {@link module:client~MatrixClient#event:"event"}
- * via {@link module:client~MatrixClient#on}.
+ * via {@link module:client~MatrixClient#on}. Alternatively, listen for specific
+ * state change events.
  * @param {Object=} opts Options to apply when syncing.
  * @param {Number=} opts.initialSyncLimit The event <code>limit=</code> to apply
  * to initial sync. Default: 8.
  * @param {Boolean=} opts.includeArchivedRooms True to put <code>archived=true</code>
  * on the <code>/initialSync</code> request. Default: false.
  * @param {Boolean=} opts.resolveInvitesToProfiles True to do /profile requests
  * on every invite event if the displayname/avatar_url is not known for this user ID.
  * Default: false.
  *
  * @param {String=} opts.pendingEventOrdering Controls where pending messages
  * appear in a room's timeline. If "<b>chronological</b>", messages will appear
  * in the timeline when the call to <code>sendEvent</code> was made. If
  * "<b>detached</b>", pending messages will appear in a separate list,
  * accessbile via {@link module:models/room#getPendingEvents}. Default:
  * "chronological".
  *
- * @param {Number=} opts.pollTimeout The number of milliseconds to wait on /events.
+ * @param {Number=} opts.pollTimeout The number of milliseconds to wait on /sync.
  * Default: 30000 (30 seconds).
  *
  * @param {Filter=} opts.filter The filter to apply to /sync calls. This will override
  * the opts.initialSyncLimit, which would normally result in a timeline limit filter.
+ *
+ * @param {Boolean=} opts.disablePresence True to perform syncing without automatically
+ * updating presence.
+ * @param {Boolean=} opts.lazyLoadMembers True to not load all membership events during
+ * initial sync but fetch them when needed by calling `loadOutOfBandMembers`
+ * This will override the filter option at this moment.
  */
-MatrixClient.prototype.startClient = function(opts) {
+MatrixClient.prototype.startClient = async function (opts) {
     if (this.clientRunning) {
         // client is already running.
         return;
     }
     this.clientRunning = true;
     // backwards compat for when 'opts' was 'historyLen'.
     if (typeof opts === "number") {
         opts = {
             initialSyncLimit: opts
         };
     }
 
-    this._clientOpts = opts;
-
     if (this._crypto) {
-        this._crypto.uploadKeys(5).done();
-        var tenMinutes = 1000 * 60 * 10;
-        var self = this;
-        this._uploadIntervalID = global.setInterval(function() {
-            self._crypto.uploadKeys(5).done();
-        }, tenMinutes);
+        this._crypto.uploadDeviceKeys().done();
+        this._crypto.start();
     }
 
     // periodically poll for turn servers if we support voip
     checkTurnServers(this);
 
     if (this._syncApi) {
         // This shouldn't happen since we thought the client was not running
-        console.error("Still have sync object whilst not running: stopping old one");
+        _logger2.default.error("Still have sync object whilst not running: stopping old one");
         this._syncApi.stop();
     }
+
+    // shallow-copy the opts dict before modifying and storing it
+    opts = Object.assign({}, opts);
+
+    opts.crypto = this._crypto;
+    opts.canResetEntireTimeline = roomId => {
+        if (!this._canResetTimelineCallback) {
+            return false;
+        }
+        return this._canResetTimelineCallback(roomId);
+    };
+    this._clientOpts = opts;
     this._syncApi = new SyncApi(this, opts);
     this._syncApi.sync();
 };
 
 /**
+ * store client options with boolean/string/numeric values
+ * to know in the next session what flags the sync data was
+ * created with (e.g. lazy loading)
+ * @param {object} opts the complete set of client options
+ * @return {Promise} for store operation */
+MatrixClient.prototype._storeClientOptions = function () {
+    const primTypes = ["boolean", "string", "number"];
+    const serializableOpts = Object.entries(this._clientOpts).filter(([key, value]) => {
+        return primTypes.includes(typeof value);
+    }).reduce((obj, [key, value]) => {
+        obj[key] = value;
+        return obj;
+    }, {});
+    return this.store.storeClientOptions(serializableOpts);
+};
+
+/**
  * High level helper method to stop the client from polling and allow a
  * clean shutdown.
  */
-MatrixClient.prototype.stopClient = function() {
+MatrixClient.prototype.stopClient = function () {
+    _logger2.default.log('stopping MatrixClient');
+
     this.clientRunning = false;
     // TODO: f.e. Room => self.store.storeRoom(room) ?
     if (this._syncApi) {
         this._syncApi.stop();
         this._syncApi = null;
     }
     if (this._crypto) {
-        global.clearInterval(this._uploadIntervalID);
+        this._crypto.stop();
+    }
+    if (this._peekSync) {
+        this._peekSync.stopPeeking();
     }
     global.clearTimeout(this._checkTurnServersTimeoutID);
 };
 
+/**
+ * Get the API versions supported by the server, along with any
+ * unstable APIs it supports
+ * @return {Promise<object>} The server /versions response
+ */
+MatrixClient.prototype.getVersions = async function () {
+    if (this._serverVersionsCache === null) {
+        this._serverVersionsCache = await this._http.request(undefined, // callback
+        "GET", "/_matrix/client/versions", undefined, // queryParams
+        undefined, // data
+        {
+            prefix: ''
+        });
+    }
+    return this._serverVersionsCache;
+};
+
+/**
+ * Query the server to see if it support members lazy loading
+ * @return {Promise<boolean>} true if server supports lazy loading
+ */
+MatrixClient.prototype.doesServerSupportLazyLoading = async function () {
+    const response = await this.getVersions();
+
+    const versions = response["versions"];
+    const unstableFeatures = response["unstable_features"];
+
+    return versions && versions.includes("r0.5.0") || unstableFeatures && unstableFeatures["m.lazy_load_members"];
+};
+
+/**
+ * Query the server to see if the `id_server` parameter is required
+ * when registering with an 3pid, adding a 3pid or resetting password.
+ * @return {Promise<boolean>} true if id_server parameter is required
+ */
+MatrixClient.prototype.doesServerRequireIdServerParam = async function () {
+    const response = await this.getVersions();
+
+    const versions = response["versions"];
+
+    // Supporting r0.6.0 is the same as having the flag set to false
+    if (versions && versions.includes("r0.6.0")) {
+        return false;
+    }
+
+    const unstableFeatures = response["unstable_features"];
+    if (unstableFeatures["m.require_identity_server"] === undefined) {
+        return true;
+    } else {
+        return unstableFeatures["m.require_identity_server"];
+    }
+};
+
+/**
+ * Query the server to see if the `id_access_token` parameter can be safely
+ * passed to the homeserver. Some homeservers may trigger errors if they are not
+ * prepared for the new parameter.
+ * @return {Promise<boolean>} true if id_access_token can be sent
+ */
+MatrixClient.prototype.doesServerAcceptIdentityAccessToken = async function () {
+    const response = await this.getVersions();
+
+    const versions = response["versions"];
+    const unstableFeatures = response["unstable_features"];
+
+    return versions && versions.includes("r0.6.0") || unstableFeatures && unstableFeatures["m.id_access_token"];
+};
+
+/**
+ * Query the server to see if it supports separate 3PID add and bind functions.
+ * This affects the sequence of API calls clients should use for these operations,
+ * so it's helpful to be able to check for support.
+ * @return {Promise<boolean>} true if separate functions are supported
+ */
+MatrixClient.prototype.doesServerSupportSeparateAddAndBind = async function () {
+    const response = await this.getVersions();
+
+    const versions = response["versions"];
+    const unstableFeatures = response["unstable_features"];
+
+    return versions && versions.includes("r0.6.0") || unstableFeatures && unstableFeatures["m.separate_add_and_bind"];
+};
+
+/**
+ * Get if lazy loading members is being used.
+ * @return {boolean} Whether or not members are lazy loaded by this client
+ */
+MatrixClient.prototype.hasLazyLoadMembersEnabled = function () {
+    return !!this._clientOpts.lazyLoadMembers;
+};
+
+/**
+ * Set a function which is called when /sync returns a 'limited' response.
+ * It is called with a room ID and returns a boolean. It should return 'true' if the SDK
+ * can SAFELY remove events from this room. It may not be safe to remove events if there
+ * are other references to the timelines for this room, e.g because the client is
+ * actively viewing events in this room.
+ * Default: returns false.
+ * @param {Function} cb The callback which will be invoked.
+ */
+MatrixClient.prototype.setCanResetTimelineCallback = function (cb) {
+    this._canResetTimelineCallback = cb;
+};
+
+/**
+ * Get the callback set via `setCanResetTimelineCallback`.
+ * @return {?Function} The callback or null
+ */
+MatrixClient.prototype.getCanResetTimelineCallback = function () {
+    return this._canResetTimelineCallback;
+};
+
+/**
+ * Returns relations for a given event. Handles encryption transparently,
+ * with the caveat that the amount of events returned might be 0, even though you get a nextBatch.
+ * When the returned promise resolves, all messages should have finished trying to decrypt.
+ * @param {string} roomId the room of the event
+ * @param {string} eventId the id of the event
+ * @param {string} relationType the rel_type of the relations requested
+ * @param {string} eventType the event type of the relations requested
+ * @param {Object} opts options with optional values for the request.
+ * @param {Object} opts.from the pagination token returned from a previous request as `nextBatch` to return following relations.
+ * @return {Object} an object with `events` as `MatrixEvent[]` and optionally `nextBatch` if more relations are available.
+ */
+MatrixClient.prototype.relations = async function (roomId, eventId, relationType, eventType, opts = {}) {
+    const fetchedEventType = _getEncryptedIfNeededEventType(this, roomId, eventType);
+    const result = await this.fetchRelations(roomId, eventId, relationType, fetchedEventType, opts);
+    const mapper = this.getEventMapper();
+    let originalEvent;
+    if (result.original_event) {
+        originalEvent = mapper(result.original_event);
+    }
+    let events = result.chunk.map(mapper);
+    if (fetchedEventType === "m.room.encrypted") {
+        const allEvents = originalEvent ? events.concat(originalEvent) : events;
+        await _bluebird2.default.all(allEvents.map(e => {
+            return new _bluebird2.default(resolve => e.once("Event.decrypted", resolve));
+        }));
+        events = events.filter(e => e.getType() === eventType);
+    }
+    return {
+        originalEvent,
+        events,
+        nextBatch: result.next_batch
+    };
+};
+
 function setupCallEventHandler(client) {
-    var candidatesByCall = {
+    const candidatesByCall = {
         // callId: [Candidate]
     };
 
     // Maintain a buffer of events before the client has synced for the first time.
     // This buffer will be inspected to see if we should send incoming call
     // notifications. It needs to be buffered to correctly determine if an
     // incoming call has had a matching answer/hangup.
-    var callEventBuffer = [];
-    var isClientPrepared = false;
-    client.on("sync", function(state) {
+    let callEventBuffer = [];
+    let isClientPrepared = false;
+    client.on("sync", function (state) {
         if (state === "PREPARED") {
             isClientPrepared = true;
-            var ignoreCallIds = {}; // Set<String>
+            const ignoreCallIds = {}; // Set<String>
             // inspect the buffer and mark all calls which have been answered
             // or hung up before passing them to the call event handler.
-            for (var i = callEventBuffer.length - 1; i >= 0; i--) {
-                var ev = callEventBuffer[i];
-                if (ev.getType() === "m.call.answer" ||
-                        ev.getType() === "m.call.hangup") {
+            for (let i = callEventBuffer.length - 1; i >= 0; i--) {
+                const ev = callEventBuffer[i];
+                if (ev.getType() === "m.call.answer" || ev.getType() === "m.call.hangup") {
                     ignoreCallIds[ev.getContent().call_id] = "yep";
                 }
             }
             // now loop through the buffer chronologically and inject them
-            callEventBuffer.forEach(function(e) {
+            callEventBuffer.forEach(function (e) {
                 if (ignoreCallIds[e.getContent().call_id]) {
+                    // This call has previously been ansered or hung up: ignore it
                     return;
                 }
                 callEventHandler(e);
             });
             callEventBuffer = [];
         }
     });
 
-    client.on("event", function(event) {
+    client.on("event", onEvent);
+
+    function onEvent(event) {
+        if (event.getType().indexOf("m.call.") !== 0) {
+            // not a call event
+            if (event.isBeingDecrypted() || event.isDecryptionFailure()) {
+                // not *yet* a call event, but might become one...
+                event.once("Event.decrypted", onEvent);
+            }
+            return;
+        }
         if (!isClientPrepared) {
-            if (event.getType().indexOf("m.call.") === 0) {
-                callEventBuffer.push(event);
-            }
+            callEventBuffer.push(event);
             return;
         }
         callEventHandler(event);
-    });
+    }
 
     function callEventHandler(event) {
-        if (event.getType().indexOf("m.call.") !== 0) {
-            return; // not a call event
-        }
-        var content = event.getContent();
-        var call = content.call_id ? client.callList[content.call_id] : undefined;
-        var i;
+        const content = event.getContent();
+        let call = content.call_id ? client.callList[content.call_id] : undefined;
+        let i;
         //console.log("RECV %s content=%s", event.getType(), JSON.stringify(content));
 
         if (event.getType() === "m.call.invite") {
             if (event.getSender() === client.credentials.userId) {
                 return; // ignore invites you send
             }
 
             if (event.getAge() > content.lifetime) {
                 return; // expired call
             }
 
             if (call && call.state === "ended") {
                 return; // stale/old invite event
             }
             if (call) {
-                console.log(
-                    "WARN: Already have a MatrixCall with id %s but got an " +
-                    "invite. Clobbering.",
-                    content.call_id
-                );
+                _logger2.default.log("WARN: Already have a MatrixCall with id %s but got an " + "invite. Clobbering.", content.call_id);
             }
 
-            call = webRtcCall.createNewMatrixCall(client, event.getRoomId());
+            call = webRtcCall.createNewMatrixCall(client, event.getRoomId(), {
+                forceTURN: client._forceTURN
+            });
             if (!call) {
-                console.log(
-                    "Incoming call ID " + content.call_id + " but this client " +
-                    "doesn't support WebRTC"
-                );
+                _logger2.default.log("Incoming call ID " + content.call_id + " but this client " + "doesn't support WebRTC");
                 // don't hang up the call: there could be other clients
                 // connected that do support WebRTC and declining the
                 // the call on their behalf would be really annoying.
                 return;
             }
 
             call.callId = content.call_id;
             call._initWithInvite(event);
             client.callList[call.callId] = call;
 
             // if we stashed candidate events for that call ID, play them back now
             if (candidatesByCall[call.callId]) {
                 for (i = 0; i < candidatesByCall[call.callId].length; i++) {
-                    call._gotRemoteIceCandidate(
-                        candidatesByCall[call.callId][i]
-                    );
+                    call._gotRemoteIceCandidate(candidatesByCall[call.callId][i]);
                 }
             }
 
             // Were we trying to call that user (room)?
-            var existingCall;
-            var existingCalls = utils.values(client.callList);
+            let existingCall;
+            const existingCalls = utils.values(client.callList);
             for (i = 0; i < existingCalls.length; ++i) {
-                var thisCall = existingCalls[i];
-                if (call.room_id === thisCall.room_id &&
-                        thisCall.direction === 'outbound' &&
-                        (["wait_local_media", "create_offer", "invite_sent"].indexOf(
-                            thisCall.state) !== -1)) {
+                const thisCall = existingCalls[i];
+                if (call.roomId === thisCall.roomId && thisCall.direction === 'outbound' && ["wait_local_media", "create_offer", "invite_sent"].indexOf(thisCall.state) !== -1) {
                     existingCall = thisCall;
                     break;
                 }
             }
 
             if (existingCall) {
                 // If we've only got to wait_local_media or create_offer and
                 // we've got an invite, pick the incoming call because we know
                 // we haven't sent our invite yet otherwise, pick whichever
                 // call has the lowest call ID (by string comparison)
-                if (existingCall.state === 'wait_local_media' ||
-                        existingCall.state === 'create_offer' ||
-                        existingCall.callId > call.callId) {
-                    console.log(
-                        "Glare detected: answering incoming call " + call.callId +
-                        " and canceling outgoing call " + existingCall.callId
-                    );
+                if (existingCall.state === 'wait_local_media' || existingCall.state === 'create_offer' || existingCall.callId > call.callId) {
+                    _logger2.default.log("Glare detected: answering incoming call " + call.callId + " and canceling outgoing call " + existingCall.callId);
                     existingCall._replacedBy(call);
                     call.answer();
-                }
-                else {
-                    console.log(
-                        "Glare detected: rejecting incoming call " + call.callId +
-                        " and keeping outgoing call " + existingCall.callId
-                    );
+                } else {
+                    _logger2.default.log("Glare detected: rejecting incoming call " + call.callId + " and keeping outgoing call " + existingCall.callId);
                     call.hangup();
                 }
-            }
-            else {
+            } else {
                 client.emit("Call.incoming", call);
             }
-        }
-        else if (event.getType() === 'm.call.answer') {
+        } else if (event.getType() === 'm.call.answer') {
             if (!call) {
                 return;
             }
             if (event.getSender() === client.credentials.userId) {
                 if (call.state === 'ringing') {
                     call._onAnsweredElsewhere(content);
                 }
-            }
-            else {
+            } else {
                 call._receivedAnswer(content);
             }
-        }
-        else if (event.getType() === 'm.call.candidates') {
+        } else if (event.getType() === 'm.call.candidates') {
             if (event.getSender() === client.credentials.userId) {
                 return;
             }
             if (!call) {
                 // store the candidates; we may get a call eventually.
                 if (!candidatesByCall[content.call_id]) {
                     candidatesByCall[content.call_id] = [];
                 }
-                candidatesByCall[content.call_id] = candidatesByCall[
-                    content.call_id
-                ].concat(content.candidates);
-            }
-            else {
+                candidatesByCall[content.call_id] = candidatesByCall[content.call_id].concat(content.candidates);
+            } else {
                 for (i = 0; i < content.candidates.length; i++) {
                     call._gotRemoteIceCandidate(content.candidates[i]);
                 }
             }
-        }
-        else if (event.getType() === 'm.call.hangup') {
+        } else if (event.getType() === 'm.call.hangup') {
             // Note that we also observe our own hangups here so we can see
             // if we've already rejected a call that would otherwise be valid
             if (!call) {
                 // if not live, store the fact that the call has ended because
                 // we're probably getting events backwards so
                 // the hangup will come before the invite
                 call = webRtcCall.createNewMatrixCall(client, event.getRoomId());
                 if (call) {
                     call.callId = content.call_id;
                     call._initWithHangup(event);
                     client.callList[content.call_id] = call;
                 }
-            }
-            else {
+            } else {
                 if (call.state !== 'ended') {
                     call._onHangupReceived(content);
                     delete client.callList[content.call_id];
                 }
             }
         }
     }
 }
@@ -2776,38 +4232,37 @@ function setupCallEventHandler(client) {
 function checkTurnServers(client) {
     if (!client._supportsVoip) {
         return;
     }
     if (client.isGuest()) {
         return; // guests can't access TURN servers
     }
 
-    client.turnServer().done(function(res) {
+    client.turnServer().done(function (res) {
         if (res.uris) {
-            console.log("Got TURN URIs: " + res.uris + " refresh in " +
-                res.ttl + " secs");
+            _logger2.default.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
             // map the response to a format that can be fed to
             // RTCPeerConnection
-            var servers = {
+            const servers = {
                 urls: res.uris,
                 username: res.username,
                 credential: res.password
             };
             client._turnServers = [servers];
             // re-fetch when we're about to reach the TTL
-            client._checkTurnServersTimeoutID =
-                setTimeout(function() { checkTurnServers(client); },
-                           (res.ttl || (60 * 60)) * 1000 * 0.9
-                          );
+            client._checkTurnServersTimeoutID = setTimeout(() => {
+                checkTurnServers(client);
+            }, (res.ttl || 60 * 60) * 1000 * 0.9);
         }
-    }, function(err) {
-        console.error("Failed to get TURN URIs");
-        client._checkTurnServersTimeoutID =
-            setTimeout(function() { checkTurnServers(client); }, 60000);
+    }, function (err) {
+        _logger2.default.error("Failed to get TURN URIs");
+        client._checkTurnServersTimeoutID = setTimeout(function () {
+            checkTurnServers(client);
+        }, 60000);
     });
 }
 
 function _reject(callback, defer, err) {
     if (callback) {
         callback(err);
     }
     defer.reject(err);
@@ -2817,49 +4272,47 @@ function _resolve(callback, defer, res) 
     if (callback) {
         callback(null, res);
     }
     defer.resolve(res);
 }
 
 function _PojoToMatrixEventMapper(client) {
     function mapper(plainOldJsObject) {
-        var event = new MatrixEvent(plainOldJsObject);
+        const event = new MatrixEvent(plainOldJsObject);
         if (event.isEncrypted()) {
-            _decryptEvent(client, event);
+            client.reEmitter.reEmit(event, ["Event.decrypted"]);
+            event.attemptDecryption(client._crypto);
+        }
+        const room = client.getRoom(event.getRoomId());
+        if (room) {
+            room.reEmitter.reEmit(event, ["Event.replaced"]);
         }
         return event;
     }
     return mapper;
 }
 
 /**
  * @return {Function}
  */
-MatrixClient.prototype.getEventMapper = function() {
+MatrixClient.prototype.getEventMapper = function () {
     return _PojoToMatrixEventMapper(this);
 };
 
 // Identity Server Operations
 // ==========================
 
 /**
  * Generates a random string suitable for use as a client secret. <strong>This
  * method is experimental and may change.</strong>
  * @return {string} A new client secret
  */
-MatrixClient.prototype.generateClientSecret = function() {
-    var ret = "";
-    var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
-
-    for (var i = 0; i < 32; i++) {
-        ret += chars.charAt(Math.floor(Math.random() * chars.length));
-    }
-
-    return ret;
+MatrixClient.prototype.generateClientSecret = function () {
+    return (0, _randomstring.randomString)(32);
 };
 
 /** */
 module.exports.MatrixClient = MatrixClient;
 /** */
 module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
 
 // MatrixClient Event JSDocs
@@ -2886,77 +4339,119 @@ module.exports.CRYPTO_ENABLED = CRYPTO_E
  * matrixClient.on("toDeviceEvent", function(event){
  *   var sender = event.getSender();
  * });
  */
 
 /**
  * Fires whenever the SDK's syncing state is updated. The state can be one of:
  * <ul>
- * <li>PREPARED : The client has synced with the server at least once and is
+ *
+ * <li>PREPARED: The client has synced with the server at least once and is
  * ready for methods to be called on it. This will be immediately followed by
  * a state of SYNCING. <i>This is the equivalent of "syncComplete" in the
  * previous API.</i></li>
+ *
+ * <li>CATCHUP: The client has detected the connection to the server might be
+ * available again and will now try to do a sync again. As this sync might take
+ * a long time (depending how long ago was last synced, and general server
+ * performance) the client is put in this mode so the UI can reflect trying
+ * to catch up with the server after losing connection.</li>
+ *
  * <li>SYNCING : The client is currently polling for new events from the server.
  * This will be called <i>after</i> processing latest events from a sync.</li>
+ *
  * <li>ERROR : The client has had a problem syncing with the server. If this is
  * called <i>before</i> PREPARED then there was a problem performing the initial
  * sync. If this is called <i>after</i> PREPARED then there was a problem polling
  * the server for updates. This may be called multiple times even if the state is
  * already ERROR. <i>This is the equivalent of "syncError" in the previous
  * API.</i></li>
- * <li>RECONNECTING: The sync connedtion has dropped, but not in a way that should
- * be considered erroneous.
+ *
+ * <li>RECONNECTING: The sync connection has dropped, but not (yet) in a way that
+ * should be considered erroneous.
  * </li>
+ *
  * <li>STOPPED: The client has stopped syncing with server due to stopClient
  * being called.
  * </li>
  * </ul>
  * State transition diagram:
  * <pre>
  *                                          +---->STOPPED
  *                                          |
  *              +----->PREPARED -------> SYNCING <--+
- *              |        ^                  ^       |
- *              |        |                  |       |
- *              |        |                  V       |
- *   null ------+        |  +-RECONNECTING<-+       |
- *              |        |  V                       |
+ *              |                        ^  |  ^    |
+ *              |      CATCHUP ----------+  |  |    |
+ *              |        ^                  V  |    |
+ *   null ------+        |  +------- RECONNECTING   |
+ *              |        V  V                       |
  *              +------->ERROR ---------------------+
  *
  * NB: 'null' will never be emitted by this event.
+ *
  * </pre>
  * Transitions:
  * <ul>
+ *
  * <li><code>null -> PREPARED</code> : Occurs when the initial sync is completed
  * first time. This involves setting up filters and obtaining push rules.
+ *
  * <li><code>null -> ERROR</code> : Occurs when the initial sync failed first time.
+ *
  * <li><code>ERROR -> PREPARED</code> : Occurs when the initial sync succeeds
  * after previously failing.
+ *
  * <li><code>PREPARED -> SYNCING</code> : Occurs immediately after transitioning
  * to PREPARED. Starts listening for live updates rather than catching up.
- * <li><code>SYNCING -> ERROR</code> : Occurs the first time a client cannot perform a
- * live update.
+ *
+ * <li><code>SYNCING -> RECONNECTING</code> : Occurs when the live update fails.
+ *
+ * <li><code>RECONNECTING -> RECONNECTING</code> : Can occur if the update calls
+ * continue to fail, but the keepalive calls (to /versions) succeed.
+ *
+ * <li><code>RECONNECTING -> ERROR</code> : Occurs when the keepalive call also fails
+ *
  * <li><code>ERROR -> SYNCING</code> : Occurs when the client has performed a
  * live update after having previously failed.
- * <li><code>ERROR -> ERROR</code> : Occurs when the client has failed to sync
+ *
+ * <li><code>ERROR -> ERROR</code> : Occurs when the client has failed to keepalive
  * for a second time or more.</li>
+ *
  * <li><code>SYNCING -> SYNCING</code> : Occurs when the client has performed a live
  * update. This is called <i>after</i> processing.</li>
+ *
  * <li><code>* -> STOPPED</code> : Occurs once the client has stopped syncing or
  * trying to sync after stopClient has been called.</li>
  * </ul>
  *
  * @event module:client~MatrixClient#"sync"
+ *
  * @param {string} state An enum representing the syncing state. One of "PREPARED",
  * "SYNCING", "ERROR", "STOPPED".
+ *
  * @param {?string} prevState An enum representing the previous syncing state.
  * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
+ *
  * @param {?Object} data Data about this transition.
- * @param {MatrixError} data.err The matrix error if <code>state=ERROR</code>.
+ *
+ * @param {MatrixError} data.error The matrix error if <code>state=ERROR</code>.
+ *
+ * @param {String} data.oldSyncToken The 'since' token passed to /sync.
+ *    <code>null</code> for the first successful sync since this client was
+ *    started. Only present if <code>state=PREPARED</code> or
+ *    <code>state=SYNCING</code>.
+ *
+ * @param {String} data.nextSyncToken The 'next_batch' result from /sync, which
+ *    will become the 'since' token for the next call to /sync. Only present if
+ *    <code>state=PREPARED</code> or <code>state=SYNCING</code>.
+ *
+ * @param {boolean} data.catchingUp True if we are working our way through a
+ *    backlog of events after connecting. Only present if <code>state=SYNCING</code>.
+ *
  * @example
  * matrixClient.on("sync", function(state, prevState, data) {
  *   switch (state) {
  *     case "ERROR":
  *       // update UI to say "Connection Lost"
  *       break;
  *     case "SYNCING":
  *       // update UI to remove any "Connection Lost" message
@@ -2964,38 +4459,49 @@ module.exports.CRYPTO_ENABLED = CRYPTO_E
  *     case "PREPARED":
  *       // the client instance is ready to be queried.
  *       var rooms = matrixClient.getRooms();
  *       break;
  *   }
  * });
  */
 
- /**
- * Fires whenever a new Room is added. This will fire when you are invited to a
- * room, as well as when you join a room. <strong>This event is experimental and
- * may change.</strong>
- * @event module:client~MatrixClient#"Room"
- * @param {Room} room The newly created, fully populated room.
- * @example
- * matrixClient.on("Room", function(room){
- *   var roomId = room.roomId;
- * });
- */
-
- /**
- * Fires whenever a Room is removed. This will fire when you forget a room.
- * <strong>This event is experimental and may change.</strong>
- * @event module:client~MatrixClient#"deleteRoom"
- * @param {string} roomId The deleted room ID.
- * @example
- * matrixClient.on("deleteRoom", function(roomId){
- *   // update UI from getRooms()
- * });
- */
+/**
+* Fires whenever the sdk learns about a new group. <strong>This event
+* is experimental and may change.</strong>
+* @event module:client~MatrixClient#"Group"
+* @param {Group} group The newly created, fully populated group.
+* @example
+* matrixClient.on("Group", function(group){
+*   var groupId = group.groupId;
+* });
+*/
+
+/**
+* Fires whenever a new Room is added. This will fire when you are invited to a
+* room, as well as when you join a room. <strong>This event is experimental and
+* may change.</strong>
+* @event module:client~MatrixClient#"Room"
+* @param {Room} room The newly created, fully populated room.
+* @example
+* matrixClient.on("Room", function(room){
+*   var roomId = room.roomId;
+* });
+*/
+
+/**
+* Fires whenever a Room is removed. This will fire when you forget a room.
+* <strong>This event is experimental and may change.</strong>
+* @event module:client~MatrixClient#"deleteRoom"
+* @param {string} roomId The deleted room ID.
+* @example
+* matrixClient.on("deleteRoom", function(roomId){
+*   // update UI from getRooms()
+* });
+*/
 
 /**
  * Fires whenever an incoming call arrives.
  * @event module:client~MatrixClient#"Call.incoming"
  * @param {module:webrtc/call~MatrixCall} call The incoming call.
  * @example
  * matrixClient.on("Call.incoming", function(call){
  *   call.answer(); // auto-answer
@@ -3004,41 +4510,98 @@ module.exports.CRYPTO_ENABLED = CRYPTO_E
 
 /**
  * Fires whenever the login session the JS SDK is using is no
  * longer valid and the user must log in again.
  * NB. This only fires when action is required from the user, not
  * when then login session can be renewed by using a refresh token.
  * @event module:client~MatrixClient#"Session.logged_out"
  * @example
- * matrixClient.on("Session.logged_out", function(call){
+ * matrixClient.on("Session.logged_out", function(errorObj){
  *   // show the login screen
  * });
  */
 
 /**
+ * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response
+ * to a HTTP request.
+ * @event module:client~MatrixClient#"no_consent"
+ * @example
+ * matrixClient.on("no_consent", function(message, contentUri) {
+ *     console.info(message + ' Go to ' + contentUri);
+ * });
+ */
+
+/**
  * Fires when a device is marked as verified/unverified/blocked/unblocked by
  * {@link module:client~MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or
  * {@link module:client~MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}.
  *
  * @event module:client~MatrixClient#"deviceVerificationChanged"
  * @param {string} userId the owner of the verified device
  * @param {string} deviceId the id of the verified device
+ * @param {module:crypto/deviceinfo} deviceInfo updated device information
  */
 
 /**
  * Fires whenever new user-scoped account_data is added.
- * @event module:client~MatrixClient#"Room"
+ * @event module:client~MatrixClient#"accountData"
  * @param {MatrixEvent} event The event describing the account_data just added
  * @example
  * matrixClient.on("accountData", function(event){
  *   myAccountData[event.type] = event.content;
  * });
  */
 
+/**
+ * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled()
+ * @event module:client~MatrixClient#"crypto.keyBackupStatus"
+ * @param {bool} enabled true if key backup has been enabled, otherwise false
+ * @example
+ * matrixClient.on("crypto.keyBackupStatus", function(enabled){
+ *   if (enabled) {
+ *     [...]
+ *   }
+ * });
+ */
+
+/**
+ * Fires when we want to suggest to the user that they restore their megolm keys
+ * from backup or by cross-signing the device.
+ *
+ * @event module:client~MatrixClient#"crypto.suggestKeyRestore"
+ */
+
+/**
+ * Fires when a key verification is requested.
+ * @event module:client~MatrixClient#"crypto.verification.request"
+ * @param {object} data
+ * @param {MatrixEvent} data.event the original verification request message
+ * @param {Array} data.methods the verification methods that can be used
+ * @param {Function} data.beginKeyVerification a function to call if a key
+ *     verification should be performed.  The function takes one argument: the
+ *     name of the key verification method (taken from data.methods) to use.
+ * @param {Function} data.cancel a function to call if the key verification is
+ *     rejected.
+ */
+
+/**
+ * Fires when a key verification is requested with an unknown method.
+ * @event module:client~MatrixClient#"crypto.verification.request.unknown"
+ * @param {string} userId the user ID who requested the key verification
+ * @param {Function} cancel a function that will send a cancellation message to
+ *     reject the key verification.
+ */
+
+/**
+ * Fires when a key verification started message is received.
+ * @event module:client~MatrixClient#"crypto.verification.start"
+ * @param {module:crypto/verification/Base} verifier a verifier object to
+ *     perform the key verification
+ */
 
 // EventEmitter JSDocs
 
 /**
  * The {@link https://nodejs.org/api/events.html|EventEmitter} class.
  * @external EventEmitter
  * @see {@link https://nodejs.org/api/events.html}
  */
@@ -3114,19 +4677,19 @@ module.exports.CRYPTO_ENABLED = CRYPTO_E
  * The standard MatrixClient callback interface. Functions which accept this
  * will specify 2 return arguments. These arguments map to the 2 parameters
  * specified in this callback.
  * @callback module:client.callback
  * @param {Object} err The error value, the "rejected" value or null.
  * @param {Object} data The data returned, the "resolved" value.
  */
 
- /**
-  * {@link https://github.com/kriskowal/q|A promise implementation (Q)}. Functions
-  * which return this will specify 2 return arguments. These arguments map to the
-  * "onFulfilled" and "onRejected" values of the Promise.
-  * @typedef {Object} Promise
-  * @static
-  * @property {Function} then promise.then(onFulfilled, onRejected, onProgress)
-  * @property {Function} catch promise.catch(onRejected)
-  * @property {Function} finally promise.finally(callback)
-  * @property {Function} done promise.done(onFulfilled, onRejected, onProgress)
-  */
+/**
+ * {@link https://github.com/kriskowal/q|A promise implementation (Q)}. Functions
+ * which return this will specify 2 return arguments. These arguments map to the
+ * "onFulfilled" and "onRejected" values of the Promise.
+ * @typedef {Object} Promise
+ * @static
+ * @property {Function} then promise.then(onFulfilled, onRejected, onProgress)
+ * @property {Function} catch promise.catch(onRejected)
+ * @property {Function} finally promise.finally(callback)
+ * @property {Function} done promise.done(onFulfilled, onRejected, onProgress)
+ */
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/content-helpers.js
@@ -0,0 +1,101 @@
+/*
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+"use strict";
+
+/** @module ContentHelpers */
+
+module.exports = {
+    /**
+     * Generates the content for a HTML Message event
+     * @param {string} body the plaintext body of the message
+     * @param {string} htmlBody the HTML representation of the message
+     * @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
+     */
+    makeHtmlMessage: function (body, htmlBody) {
+        return {
+            msgtype: "m.text",
+            format: "org.matrix.custom.html",
+            body: body,
+            formatted_body: htmlBody
+        };
+    },
+
+    /**
+     * Generates the content for a HTML Notice event
+     * @param {string} body the plaintext body of the notice
+     * @param {string} htmlBody the HTML representation of the notice
+     * @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
+     */
+    makeHtmlNotice: function (body, htmlBody) {
+        return {
+            msgtype: "m.notice",
+            format: "org.matrix.custom.html",
+            body: body,
+            formatted_body: htmlBody
+        };
+    },
+
+    /**
+     * Generates the content for a HTML Emote event
+     * @param {string} body the plaintext body of the emote
+     * @param {string} htmlBody the HTML representation of the emote
+     * @returns {{msgtype: string, format: string, body: string, formatted_body: string}}
+     */
+    makeHtmlEmote: function (body, htmlBody) {
+        return {
+            msgtype: "m.emote",
+            format: "org.matrix.custom.html",
+            body: body,
+            formatted_body: htmlBody
+        };
+    },
+
+    /**
+     * Generates the content for a Plaintext Message event
+     * @param {string} body the plaintext body of the emote
+     * @returns {{msgtype: string, body: string}}
+     */
+    makeTextMessage: function (body) {
+        return {
+            msgtype: "m.text",
+            body: body
+        };
+    },
+
+    /**
+     * Generates the content for a Plaintext Notice event
+     * @param {string} body the plaintext body of the notice
+     * @returns {{msgtype: string, body: string}}
+     */
+    makeNotice: function (body) {
+        return {
+            msgtype: "m.notice",
+            body: body
+        };
+    },
+
+    /**
+     * Generates the content for a Plaintext Emote event
+     * @param {string} body the plaintext body of the emote
+     * @returns {{msgtype: string, body: string}}
+     */
+    makeEmoteMessage: function (body) {
+        return {
+            msgtype: "m.emote",
+            body: body
+        };
+    }
+};
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/content-repo.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/content-repo.js
@@ -1,8 +1,10 @@
+"use strict";
+
 /*
 Copyright 2015, 2016 OpenMarket Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
@@ -11,17 +13,17 @@ Unless required by applicable law or agr
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 /**
  * @module content-repo
  */
-var utils = require("./utils");
+const utils = require("./utils");
 
 /** Content Repo utility functions */
 module.exports = {
     /**
      * Get the HTTP URL for an MXC URI.
      * @param {string} baseUrl The base homeserver url which has a content repo.
      * @param {string} mxc The mxc:// URI.
      * @param {Number} width The desired width of the thumbnail.
@@ -29,77 +31,77 @@ module.exports = {
      * @param {string} resizeMethod The thumbnail resize method to use, either
      * "crop" or "scale".
      * @param {Boolean} allowDirectLinks If true, return any non-mxc URLs
      * directly. Fetching such URLs will leak information about the user to
      * anyone they share a room with. If false, will return the emptry string
      * for such URLs.
      * @return {string} The complete URL to the content.
      */
-    getHttpUriForMxc: function(baseUrl, mxc, width, height,
-                               resizeMethod, allowDirectLinks) {
+    getHttpUriForMxc: function (baseUrl, mxc, width, height, resizeMethod, allowDirectLinks) {
         if (typeof mxc !== "string" || !mxc) {
             return '';
         }
         if (mxc.indexOf("mxc://") !== 0) {
             if (allowDirectLinks) {
                 return mxc;
             } else {
                 return '';
             }
         }
-        var serverAndMediaId = mxc.slice(6); // strips mxc://
-        var prefix = "/_matrix/media/v1/download/";
-        var params = {};
+        let serverAndMediaId = mxc.slice(6); // strips mxc://
+        let prefix = "/_matrix/media/r0/download/";
+        const params = {};
 
         if (width) {
-            params.width = width;
+            params.width = Math.round(width);
         }
         if (height) {
-            params.height = height;
+            params.height = Math.round(height);
         }
         if (resizeMethod) {
             params.method = resizeMethod;
         }
         if (utils.keys(params).length > 0) {
             // these are thumbnailing params so they probably want the
             // thumbnailing API...
-            prefix = "/_matrix/media/v1/thumbnail/";
+            prefix = "/_matrix/media/r0/thumbnail/";
         }
 
-        var fragmentOffset = serverAndMediaId.indexOf("#"),
-            fragment = "";
+        const fragmentOffset = serverAndMediaId.indexOf("#");
+        let fragment = "";
         if (fragmentOffset >= 0) {
             fragment = serverAndMediaId.substr(fragmentOffset);
             serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset);
         }
-        return baseUrl + prefix + serverAndMediaId +
-            (utils.keys(params).length === 0 ? "" :
-            ("?" + utils.encodeParams(params))) + fragment;
+        return baseUrl + prefix + serverAndMediaId + (utils.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params)) + fragment;
     },
 
     /**
      * Get an identicon URL from an arbitrary string.
      * @param {string} baseUrl The base homeserver url which has a content repo.
      * @param {string} identiconString The string to create an identicon for.
      * @param {Number} width The desired width of the image in pixels. Default: 96.
      * @param {Number} height The desired height of the image in pixels. Default: 96.
      * @return {string} The complete URL to the identicon.
+     * @deprecated This is no longer in the specification.
      */
-    getIdenticonUri: function(baseUrl, identiconString, width, height) {
+    getIdenticonUri: function (baseUrl, identiconString, width, height) {
         if (!identiconString) {
             return null;
         }
-        if (!width) { width = 96; }
-        if (!height) { height = 96; }
-        var params = {
+        if (!width) {
+            width = 96;
+        }
+        if (!height) {
+            height = 96;
+        }
+        const params = {
             width: width,
             height: height
         };
 
-        var path = utils.encodeUri("/_matrix/media/v1/identicon/$ident", {
+        const path = utils.encodeUri("/_matrix/media/unstable/identicon/$ident", {
             $ident: identiconString
         });
-        return baseUrl + path +
-            (utils.keys(params).length === 0 ? "" :
-                ("?" + utils.encodeParams(params)));
+        return baseUrl + path + (utils.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params));
     }
-};
+};
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/DeviceList.js
@@ -0,0 +1,875 @@
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2018, 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+"use strict";
+
+/**
+ * @module crypto/DeviceList
+ *
+ * Manages the list of other users' devices
+ */
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _deviceinfo = require('./deviceinfo');
+
+var _deviceinfo2 = _interopRequireDefault(_deviceinfo);
+
+var _olmlib = require('./olmlib');
+
+var _olmlib2 = _interopRequireDefault(_olmlib);
+
+var _indexeddbCryptoStore = require('./store/indexeddb-crypto-store');
+
+var _indexeddbCryptoStore2 = _interopRequireDefault(_indexeddbCryptoStore);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/* State transition diagram for DeviceList._deviceTrackingStatus
+ *
+ *                                |
+ *     stopTrackingDeviceList     V
+ *   +---------------------> NOT_TRACKED
+ *   |                            |
+ *   +<--------------------+      | startTrackingDeviceList
+ *   |                     |      V
+ *   |   +-------------> PENDING_DOWNLOAD <--------------------+-+
+ *   |   |                      ^ |                            | |
+ *   |   | restart     download | |  start download            | | invalidateUserDeviceList
+ *   |   | client        failed | |                            | |
+ *   |   |                      | V                            | |
+ *   |   +------------ DOWNLOAD_IN_PROGRESS -------------------+ |
+ *   |                    |       |                              |
+ *   +<-------------------+       |  download successful         |
+ *   ^                            V                              |
+ *   +----------------------- UP_TO_DATE ------------------------+
+ */
+
+// constants for DeviceList._deviceTrackingStatus
+const TRACKING_STATUS_NOT_TRACKED = 0;
+const TRACKING_STATUS_PENDING_DOWNLOAD = 1;
+const TRACKING_STATUS_DOWNLOAD_IN_PROGRESS = 2;
+const TRACKING_STATUS_UP_TO_DATE = 3;
+
+/**
+ * @alias module:crypto/DeviceList
+ */
+class DeviceList {
+    constructor(baseApis, cryptoStore, olmDevice) {
+        this._cryptoStore = cryptoStore;
+
+        // userId -> {
+        //     deviceId -> {
+        //         [device info]
+        //     }
+        // }
+        this._devices = {};
+
+        // map of identity keys to the user who owns it
+        this._userByIdentityKey = {};
+
+        // which users we are tracking device status for.
+        // userId -> TRACKING_STATUS_*
+        this._deviceTrackingStatus = {}; // loaded from storage in load()
+
+        // The 'next_batch' sync token at the point the data was writen,
+        // ie. a token representing the point immediately after the
+        // moment represented by the snapshot in the db.
+        this._syncToken = null;
+
+        this._serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this);
+
+        // userId -> promise
+        this._keyDownloadsInProgressByUser = {};
+
+        // Set whenever changes are made other than setting the sync token
+        this._dirty = false;
+
+        // Promise resolved when device data is saved
+        this._savePromise = null;
+        // Function that resolves the save promise
+        this._resolveSavePromise = null;
+        // The time the save is scheduled for
+        this._savePromiseTime = null;
+        // The timer used to delay the save
+        this._saveTimer = null;
+    }
+
+    /**
+     * Load the device tracking state from storage
+     */
+    async load() {
+        await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_DEVICE_DATA], txn => {
+            this._cryptoStore.getEndToEndDeviceData(txn, deviceData => {
+                this._devices = deviceData ? deviceData.devices : {}, this._deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
+                this._syncToken = deviceData ? deviceData.syncToken : null;
+                this._userByIdentityKey = {};
+                for (const user of Object.keys(this._devices)) {
+                    const userDevices = this._devices[user];
+                    for (const device of Object.keys(userDevices)) {
+                        const idKey = userDevices[device].keys['curve25519:' + device];
+                        if (idKey !== undefined) {
+                            this._userByIdentityKey[idKey] = user;
+                        }
+                    }
+                }
+            });
+        });
+
+        for (const u of Object.keys(this._deviceTrackingStatus)) {
+            // if a download was in progress when we got shut down, it isn't any more.
+            if (this._deviceTrackingStatus[u] == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) {
+                this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD;
+            }
+        }
+    }
+
+    stop() {
+        if (this._saveTimer !== null) {
+            clearTimeout(this._saveTimer);
+        }
+    }
+
+    /**
+     * Save the device tracking state to storage, if any changes are
+     * pending other than updating the sync token
+     *
+     * The actual save will be delayed by a short amount of time to
+     * aggregate multiple writes to the database.
+     *
+     * @param {integer} delay Time in ms before which the save actually happens.
+     *     By default, the save is delayed for a short period in order to batch
+     *     multiple writes, but this behaviour can be disabled by passing 0.
+     *
+     * @return {Promise<bool>} true if the data was saved, false if
+     *     it was not (eg. because no changes were pending). The promise
+     *     will only resolve once the data is saved, so may take some time
+     *     to resolve.
+     */
+    async saveIfDirty(delay) {
+        if (!this._dirty) return _bluebird2.default.resolve(false);
+        // Delay saves for a bit so we can aggregate multiple saves that happen
+        // in quick succession (eg. when a whole room's devices are marked as known)
+        if (delay === undefined) delay = 500;
+
+        const targetTime = Date.now + delay;
+        if (this._savePromiseTime && targetTime < this._savePromiseTime) {
+            // There's a save scheduled but for after we would like: cancel
+            // it & schedule one for the time we want
+            clearTimeout(this._saveTimer);
+            this._saveTimer = null;
+            this._savePromiseTime = null;
+            // (but keep the save promise since whatever called save before
+            // will still want to know when the save is done)
+        }
+
+        let savePromise = this._savePromise;
+        if (savePromise === null) {
+            savePromise = new _bluebird2.default((resolve, reject) => {
+                this._resolveSavePromise = resolve;
+            });
+            this._savePromise = savePromise;
+        }
+
+        if (this._saveTimer === null) {
+            const resolveSavePromise = this._resolveSavePromise;
+            this._savePromiseTime = targetTime;
+            this._saveTimer = setTimeout(() => {
+                _logger2.default.log('Saving device tracking data at token ' + this._syncToken);
+                // null out savePromise now (after the delay but before the write),
+                // otherwise we could return the existing promise when the save has
+                // actually already happened. Likewise for the dirty flag.
+                this._savePromiseTime = null;
+                this._saveTimer = null;
+                this._savePromise = null;
+                this._resolveSavePromise = null;
+
+                this._dirty = false;
+                this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_DEVICE_DATA], txn => {
+                    this._cryptoStore.storeEndToEndDeviceData({
+                        devices: this._devices,
+                        trackingStatus: this._deviceTrackingStatus,
+                        syncToken: this._syncToken
+                    }, txn);
+                }).then(() => {
+                    resolveSavePromise();
+                });
+            }, delay);
+        }
+        return savePromise;
+    }
+
+    /**
+     * Gets the sync token last set with setSyncToken
+     *
+     * @return {string} The sync token
+     */
+    getSyncToken() {
+        return this._syncToken;
+    }
+
+    /**
+     * Sets the sync token that the app will pass as the 'since' to the /sync
+     * endpoint next time it syncs.
+     * The sync token must always be set after any changes made as a result of
+     * data in that sync since setting the sync token to a newer one will mean
+     * those changed will not be synced from the server if a new client starts
+     * up with that data.
+     *
+     * @param {string} st The sync token
+     */
+    setSyncToken(st) {
+        this._syncToken = st;
+    }
+
+    /**
+     * Ensures up to date keys for a list of users are stored in the session store,
+     * downloading and storing them if they're not (or if forceDownload is
+     * true).
+     * @param {Array} userIds The users to fetch.
+     * @param {bool} forceDownload Always download the keys even if cached.
+     *
+     * @return {Promise} A promise which resolves to a map userId->deviceId->{@link
+     * module:crypto/deviceinfo|DeviceInfo}.
+     */
+    downloadKeys(userIds, forceDownload) {
+        const usersToDownload = [];
+        const promises = [];
+
+        userIds.forEach(u => {
+            const trackingStatus = this._deviceTrackingStatus[u];
+            if (this._keyDownloadsInProgressByUser[u]) {
+                // already a key download in progress/queued for this user; its results
+                // will be good enough for us.
+                _logger2.default.log(`downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`);
+                promises.push(this._keyDownloadsInProgressByUser[u]);
+            } else if (forceDownload || trackingStatus != TRACKING_STATUS_UP_TO_DATE) {
+                usersToDownload.push(u);
+            }
+        });
+
+        if (usersToDownload.length != 0) {
+            _logger2.default.log("downloadKeys: downloading for", usersToDownload);
+            const downloadPromise = this._doKeyDownload(usersToDownload);
+            promises.push(downloadPromise);
+        }
+
+        if (promises.length === 0) {
+            _logger2.default.log("downloadKeys: already have all necessary keys");
+        }
+
+        return _bluebird2.default.all(promises).then(() => {
+            return this._getDevicesFromStore(userIds);
+        });
+    }
+
+    /**
+     * Get the stored device keys for a list of user ids
+     *
+     * @param {string[]} userIds the list of users to list keys for.
+     *
+     * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}.
+     */
+    _getDevicesFromStore(userIds) {
+        const stored = {};
+        const self = this;
+        userIds.map(function (u) {
+            stored[u] = {};
+            const devices = self.getStoredDevicesForUser(u) || [];
+            devices.map(function (dev) {
+                stored[u][dev.deviceId] = dev;
+            });
+        });
+        return stored;
+    }
+
+    /**
+     * Get the stored device keys for a user id
+     *
+     * @param {string} userId the user to list keys for.
+     *
+     * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't
+     * managed to get a list of devices for this user yet.
+     */
+    getStoredDevicesForUser(userId) {
+        const devs = this._devices[userId];
+        if (!devs) {
+            return null;
+        }
+        const res = [];
+        for (const deviceId in devs) {
+            if (devs.hasOwnProperty(deviceId)) {
+                res.push(_deviceinfo2.default.fromStorage(devs[deviceId], deviceId));
+            }
+        }
+        return res;
+    }
+
+    /**
+     * Get the stored device data for a user, in raw object form
+     *
+     * @param {string} userId the user to get data for
+     *
+     * @return {Object} deviceId->{object} devices, or undefined if
+     * there is no data for this user.
+     */
+    getRawStoredDevicesForUser(userId) {
+        return this._devices[userId];
+    }
+
+    /**
+     * Get the stored keys for a single device
+     *
+     * @param {string} userId
+     * @param {string} deviceId
+     *
+     * @return {module:crypto/deviceinfo?} device, or undefined
+     * if we don't know about this device
+     */
+    getStoredDevice(userId, deviceId) {
+        const devs = this._devices[userId];
+        if (!devs || !devs[deviceId]) {
+            return undefined;
+        }
+        return _deviceinfo2.default.fromStorage(devs[deviceId], deviceId);
+    }
+
+    /**
+     * Find a device by curve25519 identity key
+     *
+     * @param {string} algorithm  encryption algorithm
+     * @param {string} senderKey  curve25519 key to match
+     *
+     * @return {module:crypto/deviceinfo?}
+     */
+    getDeviceByIdentityKey(algorithm, senderKey) {
+        const userId = this._userByIdentityKey[senderKey];
+        if (!userId) {
+            return null;
+        }
+
+        if (algorithm !== _olmlib2.default.OLM_ALGORITHM && algorithm !== _olmlib2.default.MEGOLM_ALGORITHM) {
+            // we only deal in olm keys
+            return null;
+        }
+
+        const devices = this._devices[userId];
+        if (!devices) {
+            return null;
+        }
+
+        for (const deviceId in devices) {
+            if (!devices.hasOwnProperty(deviceId)) {
+                continue;
+            }
+
+            const device = devices[deviceId];
+            for (const keyId in device.keys) {
+                if (!device.keys.hasOwnProperty(keyId)) {
+                    continue;
+                }
+                if (keyId.indexOf("curve25519:") !== 0) {
+                    continue;
+                }
+                const deviceKey = device.keys[keyId];
+                if (deviceKey == senderKey) {
+                    return _deviceinfo2.default.fromStorage(device, deviceId);
+                }
+            }
+        }
+
+        // doesn't match a known device
+        return null;
+    }
+
+    /**
+     * Replaces the list of devices for a user with the given device list
+     *
+     * @param {string} u The user ID
+     * @param {Object} devs New device info for user
+     */
+    storeDevicesForUser(u, devs) {
+        // remove previous devices from _userByIdentityKey
+        if (this._devices[u] !== undefined) {
+            for (const [deviceId, dev] of Object.entries(this._devices[u])) {
+                const identityKey = dev.keys['curve25519:' + deviceId];
+
+                delete this._userByIdentityKey[identityKey];
+            }
+        }
+
+        this._devices[u] = devs;
+
+        // add new ones
+        for (const [deviceId, dev] of Object.entries(devs)) {
+            const identityKey = dev.keys['curve25519:' + deviceId];
+
+            this._userByIdentityKey[identityKey] = u;
+        }
+        this._dirty = true;
+    }
+
+    /**
+     * flag the given user for device-list tracking, if they are not already.
+     *
+     * This will mean that a subsequent call to refreshOutdatedDeviceLists()
+     * will download the device list for the user, and that subsequent calls to
+     * invalidateUserDeviceList will trigger more updates.
+     *
+     * @param {String} userId
+     */
+    startTrackingDeviceList(userId) {
+        // sanity-check the userId. This is mostly paranoia, but if synapse
+        // can't parse the userId we give it as an mxid, it 500s the whole
+        // request and we can never update the device lists again (because
+        // the broken userId is always 'invalid' and always included in any
+        // refresh request).
+        // By checking it is at least a string, we can eliminate a class of
+        // silly errors.
+        if (typeof userId !== 'string') {
+            throw new Error('userId must be a string; was ' + userId);
+        }
+        if (!this._deviceTrackingStatus[userId]) {
+            _logger2.default.log('Now tracking device list for ' + userId);
+            this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD;
+            // we don't yet persist the tracking status, since there may be a lot
+            // of calls; we save all data together once the sync is done
+            this._dirty = true;
+        }
+    }
+
+    /**
+     * Mark the given user as no longer being tracked for device-list updates.
+     *
+     * This won't affect any in-progress downloads, which will still go on to
+     * complete; it will just mean that we don't think that we have an up-to-date
+     * list for future calls to downloadKeys.
+     *
+     * @param {String} userId
+     */
+    stopTrackingDeviceList(userId) {
+        if (this._deviceTrackingStatus[userId]) {
+            _logger2.default.log('No longer tracking device list for ' + userId);
+            this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED;
+
+            // we don't yet persist the tracking status, since there may be a lot
+            // of calls; we save all data together once the sync is done
+            this._dirty = true;
+        }
+    }
+
+    /**
+     * Set all users we're currently tracking to untracked
+     *
+     * This will flag each user whose devices we are tracking as in need of an
+     * update.
+     */
+    stopTrackingAllDeviceLists() {
+        for (const userId of Object.keys(this._deviceTrackingStatus)) {
+            this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED;
+        }
+        this._dirty = true;
+    }
+
+    /**
+     * Mark the cached device list for the given user outdated.
+     *
+     * If we are not tracking this user's devices, we'll do nothing. Otherwise
+     * we flag the user as needing an update.
+     *
+     * This doesn't actually set off an update, so that several users can be
+     * batched together. Call refreshOutdatedDeviceLists() for that.
+     *
+     * @param {String} userId
+     */
+    invalidateUserDeviceList(userId) {
+        if (this._deviceTrackingStatus[userId]) {
+            _logger2.default.log("Marking device list outdated for", userId);
+            this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD;
+
+            // we don't yet persist the tracking status, since there may be a lot
+            // of calls; we save all data together once the sync is done
+            this._dirty = true;
+        }
+    }
+
+    /**
+     * If we have users who have outdated device lists, start key downloads for them
+     *
+     * @returns {Promise} which completes when the download completes; normally there
+     *    is no need to wait for this (it's mostly for the unit tests).
+     */
+    refreshOutdatedDeviceLists() {
+        this.saveIfDirty();
+
+        const usersToDownload = [];
+        for (const userId of Object.keys(this._deviceTrackingStatus)) {
+            const stat = this._deviceTrackingStatus[userId];
+            if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) {
+                usersToDownload.push(userId);
+            }
+        }
+
+        return this._doKeyDownload(usersToDownload);
+    }
+
+    /**
+     * Set the stored device data for a user, in raw object form
+     * Used only by internal class DeviceListUpdateSerialiser
+     *
+     * @param {string} userId the user to get data for
+     *
+     * @param {Object} devices deviceId->{object} the new devices
+     */
+    _setRawStoredDevicesForUser(userId, devices) {
+        // remove old devices from _userByIdentityKey
+        if (this._devices[userId] !== undefined) {
+            for (const [deviceId, dev] of Object.entries(this._devices[userId])) {
+                const identityKey = dev.keys['curve25519:' + deviceId];
+
+                delete this._userByIdentityKey[identityKey];
+            }
+        }
+
+        this._devices[userId] = devices;
+
+        // add new devices into _userByIdentityKey
+        for (const [deviceId, dev] of Object.entries(devices)) {
+            const identityKey = dev.keys['curve25519:' + deviceId];
+
+            this._userByIdentityKey[identityKey] = userId;
+        }
+    }
+
+    /**
+     * Fire off download update requests for the given users, and update the
+     * device list tracking status for them, and the
+     * _keyDownloadsInProgressByUser map for them.
+     *
+     * @param {String[]} users  list of userIds
+     *
+     * @return {module:client.Promise} resolves when all the users listed have
+     *     been updated. rejects if there was a problem updating any of the
+     *     users.
+     */
+    _doKeyDownload(users) {
+        if (users.length === 0) {
+            // nothing to do
+            return _bluebird2.default.resolve();
+        }
+
+        const prom = this._serialiser.updateDevicesForUsers(users, this._syncToken).then(() => {
+            finished(true);
+        }, e => {
+            _logger2.default.error('Error downloading keys for ' + users + ":", e);
+            finished(false);
+            throw e;
+        });
+
+        users.forEach(u => {
+            this._keyDownloadsInProgressByUser[u] = prom;
+            const stat = this._deviceTrackingStatus[u];
+            if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) {
+                this._deviceTrackingStatus[u] = TRACKING_STATUS_DOWNLOAD_IN_PROGRESS;
+            }
+        });
+
+        const finished = success => {
+            users.forEach(u => {
+                this._dirty = true;
+
+                // we may have queued up another download request for this user
+                // since we started this request. If that happens, we should
+                // ignore the completion of the first one.
+                if (this._keyDownloadsInProgressByUser[u] !== prom) {
+                    _logger2.default.log('Another update in the queue for', u, '- not marking up-to-date');
+                    return;
+                }
+                delete this._keyDownloadsInProgressByUser[u];
+                const stat = this._deviceTrackingStatus[u];
+                if (stat == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) {
+                    if (success) {
+                        // we didn't get any new invalidations since this download started:
+                        // this user's device list is now up to date.
+                        this._deviceTrackingStatus[u] = TRACKING_STATUS_UP_TO_DATE;
+                        _logger2.default.log("Device list for", u, "now up to date");
+                    } else {
+                        this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD;
+                    }
+                }
+            });
+            this.saveIfDirty();
+        };
+
+        return prom;
+    }
+}
+
+exports.default = DeviceList; /**
+                               * Serialises updates to device lists
+                               *
+                               * Ensures that results from /keys/query are not overwritten if a second call
+                               * completes *before* an earlier one.
+                               *
+                               * It currently does this by ensuring only one call to /keys/query happens at a
+                               * time (and queuing other requests up).
+                               */
+
+class DeviceListUpdateSerialiser {
+    /*
+     * @param {object} baseApis Base API object
+     * @param {object} olmDevice The Olm Device
+     * @param {object} deviceList The device list object
+     */
+    constructor(baseApis, olmDevice, deviceList) {
+        this._baseApis = baseApis;
+        this._olmDevice = olmDevice;
+        this._deviceList = deviceList; // the device list to be updated
+
+        this._downloadInProgress = false;
+
+        // users which are queued for download
+        // userId -> true
+        this._keyDownloadsQueuedByUser = {};
+
+        // deferred which is resolved when the queued users are downloaded.
+        //
+        // non-null indicates that we have users queued for download.
+        this._queuedQueryDeferred = null;
+
+        this._syncToken = null; // The sync token we send with the requests
+    }
+
+    /**
+     * Make a key query request for the given users
+     *
+     * @param {String[]} users list of user ids
+     *
+     * @param {String} syncToken sync token to pass in the query request, to
+     *     help the HS give the most recent results
+     *
+     * @return {module:client.Promise} resolves when all the users listed have
+     *     been updated. rejects if there was a problem updating any of the
+     *     users.
+     */
+    updateDevicesForUsers(users, syncToken) {
+        users.forEach(u => {
+            this._keyDownloadsQueuedByUser[u] = true;
+        });
+
+        if (!this._queuedQueryDeferred) {
+            this._queuedQueryDeferred = _bluebird2.default.defer();
+        }
+
+        // We always take the new sync token and just use the latest one we've
+        // been given, since it just needs to be at least as recent as the
+        // sync response the device invalidation message arrived in
+        this._syncToken = syncToken;
+
+        if (this._downloadInProgress) {
+            // just queue up these users
+            _logger2.default.log('Queued key download for', users);
+            return this._queuedQueryDeferred.promise;
+        }
+
+        // start a new download.
+        return this._doQueuedQueries();
+    }
+
+    _doQueuedQueries() {
+        if (this._downloadInProgress) {
+            throw new Error("DeviceListUpdateSerialiser._doQueuedQueries called with request active");
+        }
+
+        const downloadUsers = Object.keys(this._keyDownloadsQueuedByUser);
+        this._keyDownloadsQueuedByUser = {};
+        const deferred = this._queuedQueryDeferred;
+        this._queuedQueryDeferred = null;
+
+        _logger2.default.log('Starting key download for', downloadUsers);
+        this._downloadInProgress = true;
+
+        const opts = {};
+        if (this._syncToken) {
+            opts.token = this._syncToken;
+        }
+
+        this._baseApis.downloadKeysForUsers(downloadUsers, opts).then(res => {
+            const dk = res.device_keys || {};
+
+            // do each user in a separate promise, to avoid wedging the CPU
+            // (https://github.com/vector-im/riot-web/issues/3158)
+            //
+            // of course we ought to do this in a web worker or similar, but
+            // this serves as an easy solution for now.
+            let prom = _bluebird2.default.resolve();
+            for (const userId of downloadUsers) {
+                prom = prom.delay(5).then(() => {
+                    return this._processQueryResponseForUser(userId, dk[userId]);
+                });
+            }
+
+            return prom;
+        }).done(() => {
+            _logger2.default.log('Completed key download for ' + downloadUsers);
+
+            this._downloadInProgress = false;
+            deferred.resolve();
+
+            // if we have queued users, fire off another request.
+            if (this._queuedQueryDeferred) {
+                this._doQueuedQueries();
+            }
+        }, e => {
+            _logger2.default.warn('Error downloading keys for ' + downloadUsers + ':', e);
+            this._downloadInProgress = false;
+            deferred.reject(e);
+        });
+
+        return deferred.promise;
+    }
+
+    async _processQueryResponseForUser(userId, response) {
+        _logger2.default.log('got keys for ' + userId + ':', response);
+
+        // map from deviceid -> deviceinfo for this user
+        const userStore = {};
+        const devs = this._deviceList.getRawStoredDevicesForUser(userId);
+        if (devs) {
+            Object.keys(devs).forEach(deviceId => {
+                const d = _deviceinfo2.default.fromStorage(devs[deviceId], deviceId);
+                userStore[deviceId] = d;
+            });
+        }
+
+        await _updateStoredDeviceKeysForUser(this._olmDevice, userId, userStore, response || {});
+
+        // put the updates into thr object that will be returned as our results
+        const storage = {};
+        Object.keys(userStore).forEach(deviceId => {
+            storage[deviceId] = userStore[deviceId].toStorage();
+        });
+
+        this._deviceList._setRawStoredDevicesForUser(userId, storage);
+    }
+}
+
+async function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore, userResult) {
+    let updated = false;
+
+    // remove any devices in the store which aren't in the response
+    for (const deviceId in userStore) {
+        if (!userStore.hasOwnProperty(deviceId)) {
+            continue;
+        }
+
+        if (!(deviceId in userResult)) {
+            _logger2.default.log("Device " + userId + ":" + deviceId + " has been removed");
+            delete userStore[deviceId];
+            updated = true;
+        }
+    }
+
+    for (const deviceId in userResult) {
+        if (!userResult.hasOwnProperty(deviceId)) {
+            continue;
+        }
+
+        const deviceResult = userResult[deviceId];
+
+        // check that the user_id and device_id in the response object are
+        // correct
+        if (deviceResult.user_id !== userId) {
+            _logger2.default.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId);
+            continue;
+        }
+        if (deviceResult.device_id !== deviceId) {
+            _logger2.default.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId);
+            continue;
+        }
+
+        if (await _storeDeviceKeys(_olmDevice, userStore, deviceResult)) {
+            updated = true;
+        }
+    }
+
+    return updated;
+}
+
+/*
+ * Process a device in a /query response, and add it to the userStore
+ *
+ * returns (a promise for) true if a change was made, else false
+ */
+async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
+    if (!deviceResult.keys) {
+        // no keys?
+        return false;
+    }
+
+    const deviceId = deviceResult.device_id;
+    const userId = deviceResult.user_id;
+
+    const signKeyId = "ed25519:" + deviceId;
+    const signKey = deviceResult.keys[signKeyId];
+    if (!signKey) {
+        _logger2.default.warn("Device " + userId + ":" + deviceId + " has no ed25519 key");
+        return false;
+    }
+
+    const unsigned = deviceResult.unsigned || {};
+
+    try {
+        await _olmlib2.default.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey);
+    } catch (e) {
+        _logger2.default.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e);
+        return false;
+    }
+
+    // DeviceInfo
+    let deviceStore;
+
+    if (deviceId in userStore) {
+        // already have this device.
+        deviceStore = userStore[deviceId];
+
+        if (deviceStore.getFingerprint() != signKey) {
+            // this should only happen if the list has been MITMed; we are
+            // best off sticking with the original keys.
+            //
+            // Should we warn the user about it somehow?
+            _logger2.default.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed");
+            return false;
+        }
+    } else {
+        userStore[deviceId] = deviceStore = new _deviceinfo2.default(deviceId);
+    }
+
+    deviceStore.keys = deviceResult.keys || {};
+    deviceStore.algorithms = deviceResult.algorithms || [];
+    deviceStore.unsigned = unsigned;
+    return true;
+}
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/OlmDevice.js
@@ -1,744 +1,992 @@
+'use strict';
+
+var _logger = require('../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _indexeddbCryptoStore = require('./store/indexeddb-crypto-store');
+
+var _indexeddbCryptoStore2 = _interopRequireDefault(_indexeddbCryptoStore);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+// The maximum size of an event is 65K, and we base64 the content, so this is a
+// reasonable approximation to the biggest plaintext we can encrypt.
 /*
 Copyright 2016 OpenMarket Ltd
+Copyright 2017, 2019 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
-"use strict";
+
+const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4;
+
+function checkPayloadLength(payloadString) {
+    if (payloadString === undefined) {
+        throw new Error("payloadString undefined");
+    }
+
+    if (payloadString.length > MAX_PLAINTEXT_LENGTH) {
+        // might as well fail early here rather than letting the olm library throw
+        // a cryptic memory allocation error.
+        //
+        // Note that even if we manage to do the encryption, the message send may fail,
+        // because by the time we've wrapped the ciphertext in the event object, it may
+        // exceed 65K. But at least we won't just fail with "abort()" in that case.
+        throw new Error("Message too long (" + payloadString.length + " bytes). " + "The maximum for an encrypted message is " + MAX_PLAINTEXT_LENGTH + " bytes.");
+    }
+}
 
 /**
- * olm.js wrapper
+ * The type of object we use for importing and exporting megolm session data.
  *
- * @module crypto/OlmDevice
+ * @typedef {Object} module:crypto/OlmDevice.MegolmSessionData
+ * @property {String} sender_key  Sender's Curve25519 device key
+ * @property {String[]} forwarding_curve25519_key_chain Devices which forwarded
+ *     this session to us (normally empty).
+ * @property {Object<string, string>} sender_claimed_keys Other keys the sender claims.
+ * @property {String} room_id     Room this session is used in
+ * @property {String} session_id  Unique id for the session
+ * @property {String} session_key Base64'ed key data
  */
 
-var Olm = require("olm");
-var utils = require("../utils");
-
 /**
  * Manages the olm cryptography functions. Each OlmDevice has a single
  * OlmAccount and a number of OlmSessions.
  *
- * Accounts and sessions are kept pickled in a sessionStore.
+ * Accounts and sessions are kept pickled in the cryptoStore.
  *
  * @constructor
  * @alias module:crypto/OlmDevice
  *
- * @param {Object} sessionStore A store to be used for data in end-to-end
- *    crypto
+ * @param {Object} cryptoStore A store for crypto data
  *
  * @property {string} deviceCurve25519Key   Curve25519 key for the account
  * @property {string} deviceEd25519Key      Ed25519 key for the account
  */
-function OlmDevice(sessionStore) {
-    this._sessionStore = sessionStore;
+function OlmDevice(cryptoStore) {
+    this._cryptoStore = cryptoStore;
     this._pickleKey = "DEFAULT_KEY";
 
-    var e2eKeys;
-    var account = new Olm.Account();
-    try {
-        _initialise_account(this._sessionStore, this._pickleKey, account);
-        e2eKeys = JSON.parse(account.identity_keys());
-    } finally {
-        account.free();
-    }
+    // don't know these until we load the account from storage in init()
+    this.deviceCurve25519Key = null;
+    this.deviceEd25519Key = null;
+    this._maxOneTimeKeys = null;
 
-    this.deviceCurve25519Key = e2eKeys.curve25519;
-    this.deviceEd25519Key = e2eKeys.ed25519;
-
-    // we don't bother stashing outboundgroupsessions in the sessionstore -
+    // we don't bother stashing outboundgroupsessions in the cryptoStore -
     // instead we keep them here.
     this._outboundGroupSessionStore = {};
 
     // Store a set of decrypted message indexes for each group session.
     // This partially mitigates a replay attack where a MITM resends a group
     // message into the room.
     //
-    // TODO: If we ever remove an event from memory we will also need to remove
-    // it from this map. Otherwise if we download the event from the server we
-    // will think that it is a duplicate.
+    // When we decrypt a message and the message index matches a previously
+    // decrypted message, one possible cause of that is that we are decrypting
+    // the same event, and may not indicate an actual replay attack.  For
+    // example, this could happen if we receive events, forget about them, and
+    // then re-fetch them when we backfill.  So we store the event ID and
+    // timestamp corresponding to each message index when we first decrypt it,
+    // and compare these against the event ID and timestamp every time we use
+    // that same index.  If they match, then we're probably decrypting the same
+    // event and we don't consider it a replay attack.
     //
     // Keys are strings of form "<senderKey>|<session_id>|<message_index>"
-    // Values are true.
+    // Values are objects of the form "{id: <event id>, timestamp: <ts>}"
     this._inboundGroupSessionMessageIndexes = {};
+
+    // Keep track of sessions that we're starting, so that we don't start
+    // multiple sessions for the same device at the same time.
+    this._sessionsInProgress = {};
 }
 
-function _initialise_account(sessionStore, pickleKey, account) {
-    var e2eAccount = sessionStore.getEndToEndAccount();
-    if (e2eAccount !== null) {
-        account.unpickle(pickleKey, e2eAccount);
-        return;
+/**
+ * Initialise the OlmAccount. This must be called before any other operations
+ * on the OlmDevice.
+ *
+ * Attempts to load the OlmAccount from the crypto store, or creates one if none is
+ * found.
+ *
+ * Reads the device keys from the OlmAccount object.
+ */
+OlmDevice.prototype.init = async function () {
+    let e2eKeys;
+    const account = new global.Olm.Account();
+    try {
+        await _initialiseAccount(this._cryptoStore, this._pickleKey, account);
+        e2eKeys = JSON.parse(account.identity_keys());
+
+        this._maxOneTimeKeys = account.max_number_of_one_time_keys();
+    } finally {
+        account.free();
     }
 
-    account.create();
-    var pickled = account.pickle(pickleKey);
-    sessionStore.storeEndToEndAccount(pickled);
+    this.deviceCurve25519Key = e2eKeys.curve25519;
+    this.deviceEd25519Key = e2eKeys.ed25519;
+};
+
+async function _initialiseAccount(cryptoStore, pickleKey, account) {
+    await cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ACCOUNT], txn => {
+        cryptoStore.getAccount(txn, pickledAccount => {
+            if (pickledAccount !== null) {
+                account.unpickle(pickleKey, pickledAccount);
+            } else {
+                account.create();
+                pickledAccount = account.pickle(pickleKey);
+                cryptoStore.storeAccount(txn, pickledAccount);
+            }
+        });
+    });
 }
 
 /**
  * @return {array} The version of Olm.
  */
-OlmDevice.getOlmVersion = function() {
-    return Olm.get_library_version();
+OlmDevice.getOlmVersion = function () {
+    return global.Olm.get_library_version();
 };
 
-
 /**
- * extract our OlmAccount from the session store and call the given function
+ * extract our OlmAccount from the crypto store and call the given function
+ * with the account object
+ * The `account` object is useable only within the callback passed to this
+ * function and will be freed as soon the callback returns. It is *not*
+ * useable for the rest of the lifetime of the transaction.
+ * This function requires a live transaction object from cryptoStore.doTxn()
+ * and therefore may only be called in a doTxn() callback.
  *
+ * @param {*} txn Opaque transaction object from cryptoStore.doTxn()
  * @param {function} func
- * @return {object} result of func
  * @private
  */
-OlmDevice.prototype._getAccount = function(func) {
-    var account = new Olm.Account();
-    try {
-        var pickledAccount = this._sessionStore.getEndToEndAccount();
-        account.unpickle(this._pickleKey, pickledAccount);
-        return func(account);
-    } finally {
-        account.free();
-    }
+OlmDevice.prototype._getAccount = function (txn, func) {
+    this._cryptoStore.getAccount(txn, pickledAccount => {
+        const account = new global.Olm.Account();
+        try {
+            account.unpickle(this._pickleKey, pickledAccount);
+            func(account);
+        } finally {
+            account.free();
+        }
+    });
 };
 
-
-/**
- * store our OlmAccount in the session store
+/*
+ * Saves an account to the crypto store.
+ * This function requires a live transaction object from cryptoStore.doTxn()
+ * and therefore may only be called in a doTxn() callback.
  *
- * @param {OlmAccount} account
+ * @param {*} txn Opaque transaction object from cryptoStore.doTxn()
+ * @param {object} Olm.Account object
  * @private
  */
-OlmDevice.prototype._saveAccount = function(account) {
-    var pickledAccount = account.pickle(this._pickleKey);
-    this._sessionStore.storeEndToEndAccount(pickledAccount);
+OlmDevice.prototype._storeAccount = function (txn, account) {
+    this._cryptoStore.storeAccount(txn, account.pickle(this._pickleKey));
 };
 
-
 /**
  * extract an OlmSession from the session store and call the given function
+ * The session is useable only within the callback passed to this
+ * function and will be freed as soon the callback returns. It is *not*
+ * useable for the rest of the lifetime of the transaction.
  *
  * @param {string} deviceKey
  * @param {string} sessionId
+ * @param {*} txn Opaque transaction object from cryptoStore.doTxn()
  * @param {function} func
- * @return {object} result of func
  * @private
  */
-OlmDevice.prototype._getSession = function(deviceKey, sessionId, func) {
-    var sessions = this._sessionStore.getEndToEndSessions(deviceKey);
-    var pickledSession = sessions[sessionId];
+OlmDevice.prototype._getSession = function (deviceKey, sessionId, txn, func) {
+    this._cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, sessionInfo => {
+        this._unpickleSession(sessionInfo, func);
+    });
+};
 
-    var session = new Olm.Session();
+/**
+ * Creates a session object from a session pickle and executes the given
+ * function with it. The session object is destroyed once the function
+ * returns.
+ *
+ * @param {object} sessionInfo
+ * @param {function} func
+ * @private
+ */
+OlmDevice.prototype._unpickleSession = function (sessionInfo, func) {
+    const session = new global.Olm.Session();
     try {
-        session.unpickle(this._pickleKey, pickledSession);
-        return func(session);
+        session.unpickle(this._pickleKey, sessionInfo.session);
+        const unpickledSessInfo = Object.assign({}, sessionInfo, { session });
+
+        func(unpickledSessInfo);
     } finally {
         session.free();
     }
 };
 
-
 /**
  * store our OlmSession in the session store
  *
  * @param {string} deviceKey
- * @param {OlmSession} session
+ * @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int}
+ * @param {*} txn Opaque transaction object from cryptoStore.doTxn()
  * @private
  */
-OlmDevice.prototype._saveSession = function(deviceKey, session) {
-    var pickledSession = session.pickle(this._pickleKey);
-    this._sessionStore.storeEndToEndSession(
-        deviceKey, session.session_id(), pickledSession
-    );
+OlmDevice.prototype._saveSession = function (deviceKey, sessionInfo, txn) {
+    const sessionId = sessionInfo.session.session_id();
+    const pickledSessionInfo = Object.assign(sessionInfo, {
+        session: sessionInfo.session.pickle(this._pickleKey)
+    });
+    this._cryptoStore.storeEndToEndSession(deviceKey, sessionId, pickledSessionInfo, txn);
 };
 
-
 /**
  * get an OlmUtility and call the given function
  *
  * @param {function} func
  * @return {object} result of func
  * @private
  */
-OlmDevice.prototype._getUtility = function(func) {
-    var utility = new Olm.Utility();
+OlmDevice.prototype._getUtility = function (func) {
+    const utility = new global.Olm.Utility();
     try {
         return func(utility);
     } finally {
         utility.free();
     }
 };
 
-
 /**
  * Signs a message with the ed25519 key for this account.
  *
  * @param {string} message  message to be signed
- * @return {string} base64-encoded signature
+ * @return {Promise<string>} base64-encoded signature
  */
-OlmDevice.prototype.sign = function(message) {
-    return this._getAccount(function(account) {
-        return account.sign(message);
+OlmDevice.prototype.sign = async function (message) {
+    let result;
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_ACCOUNT], txn => {
+        this._getAccount(txn, account => {
+            result = account.sign(message);
+        });
     });
+    return result;
 };
 
 /**
  * Get the current (unused, unpublished) one-time keys for this account.
  *
  * @return {object} one time keys; an object with the single property
  * <tt>curve25519</tt>, which is itself an object mapping key id to Curve25519
  * key.
  */
-OlmDevice.prototype.getOneTimeKeys = function() {
-    return this._getAccount(function(account) {
-        return JSON.parse(account.one_time_keys());
+OlmDevice.prototype.getOneTimeKeys = async function () {
+    let result;
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_ACCOUNT], txn => {
+        this._getAccount(txn, account => {
+            result = JSON.parse(account.one_time_keys());
+        });
     });
+
+    return result;
 };
 
-
 /**
  * Get the maximum number of one-time keys we can store.
  *
  * @return {number} number of keys
  */
-OlmDevice.prototype.maxNumberOfOneTimeKeys = function() {
-    return this._getAccount(function(account) {
-        return account.max_number_of_one_time_keys();
-    });
+OlmDevice.prototype.maxNumberOfOneTimeKeys = function () {
+    return this._maxOneTimeKeys;
 };
 
 /**
  * Marks all of the one-time keys as published.
  */
-OlmDevice.prototype.markKeysAsPublished = function() {
-    var self = this;
-    this._getAccount(function(account) {
-        account.mark_keys_as_published();
-        self._saveAccount(account);
+OlmDevice.prototype.markKeysAsPublished = async function () {
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ACCOUNT], txn => {
+        this._getAccount(txn, account => {
+            account.mark_keys_as_published();
+            this._storeAccount(txn, account);
+        });
     });
 };
 
 /**
  * Generate some new one-time keys
  *
  * @param {number} numKeys number of keys to generate
+ * @return {Promise} Resolved once the account is saved back having generated the keys
  */
-OlmDevice.prototype.generateOneTimeKeys = function(numKeys) {
-    var self = this;
-    this._getAccount(function(account) {
-        account.generate_one_time_keys(numKeys);
-        self._saveAccount(account);
+OlmDevice.prototype.generateOneTimeKeys = function (numKeys) {
+    return this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ACCOUNT], txn => {
+        this._getAccount(txn, account => {
+            account.generate_one_time_keys(numKeys);
+            this._storeAccount(txn, account);
+        });
     });
 };
 
 /**
  * Generate a new outbound session
  *
- * The new session will be stored in the sessionStore.
+ * The new session will be stored in the cryptoStore.
  *
  * @param {string} theirIdentityKey remote user's Curve25519 identity key
  * @param {string} theirOneTimeKey  remote user's one-time Curve25519 key
  * @return {string} sessionId for the outbound session.
  */
-OlmDevice.prototype.createOutboundSession = function(
-    theirIdentityKey, theirOneTimeKey
-) {
-    var self = this;
-    return this._getAccount(function(account) {
-        var session = new Olm.Session();
-        try {
-            session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
-            self._saveSession(theirIdentityKey, session);
-            return session.session_id();
-        } finally {
-            session.free();
-        }
+OlmDevice.prototype.createOutboundSession = async function (theirIdentityKey, theirOneTimeKey) {
+    let newSessionId;
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ACCOUNT, _indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._getAccount(txn, account => {
+            const session = new global.Olm.Session();
+            try {
+                session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
+                newSessionId = session.session_id();
+                this._storeAccount(txn, account);
+                const sessionInfo = {
+                    session,
+                    // Pretend we've received a message at this point, otherwise
+                    // if we try to send a message to the device, it won't use
+                    // this session
+                    lastReceivedMessageTs: Date.now()
+                };
+                this._saveSession(theirIdentityKey, sessionInfo, txn);
+            } finally {
+                session.free();
+            }
+        });
     });
+    return newSessionId;
 };
 
-
 /**
  * Generate a new inbound session, given an incoming message
  *
  * @param {string} theirDeviceIdentityKey remote user's Curve25519 identity key
- * @param {number} message_type  message_type field from the received message (must be 0)
+ * @param {number} messageType  messageType field from the received message (must be 0)
  * @param {string} ciphertext base64-encoded body from the received message
  *
  * @return {{payload: string, session_id: string}} decrypted payload, and
  *     session id of new session
  *
  * @raises {Error} if the received message was not valid (for instance, it
  *     didn't use a valid one-time key).
  */
-OlmDevice.prototype.createInboundSession = function(
-    theirDeviceIdentityKey, message_type, ciphertext
-) {
-    if (message_type !== 0) {
-        throw new Error("Need message_type == 0 to create inbound session");
+OlmDevice.prototype.createInboundSession = async function (theirDeviceIdentityKey, messageType, ciphertext) {
+    if (messageType !== 0) {
+        throw new Error("Need messageType == 0 to create inbound session");
     }
 
-    var self = this;
-    return this._getAccount(function(account) {
-        var session = new Olm.Session();
-        try {
-            session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext);
-            account.remove_one_time_keys(session);
-            self._saveAccount(account);
+    let result;
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ACCOUNT, _indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._getAccount(txn, account => {
+            const session = new global.Olm.Session();
+            try {
+                session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext);
+                account.remove_one_time_keys(session);
+                this._storeAccount(txn, account);
 
-            var payloadString = session.decrypt(message_type, ciphertext);
+                const payloadString = session.decrypt(messageType, ciphertext);
 
-            self._saveSession(theirDeviceIdentityKey, session);
+                const sessionInfo = {
+                    session,
+                    // this counts as a received message: set last received message time
+                    // to now
+                    lastReceivedMessageTs: Date.now()
+                };
+                this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
 
-            return {
-                payload: payloadString,
-                session_id: session.session_id(),
-            };
-        } finally {
-            session.free();
-        }
+                result = {
+                    payload: payloadString,
+                    session_id: session.session_id()
+                };
+            } finally {
+                session.free();
+            }
+        });
     });
+
+    return result;
 };
 
-
 /**
  * Get a list of known session IDs for the given device
  *
  * @param {string} theirDeviceIdentityKey Curve25519 identity key for the
  *     remote device
- * @return {string[]}  a list of known session ids for the device
+ * @return {Promise<string[]>}  a list of known session ids for the device
  */
-OlmDevice.prototype.getSessionIdsForDevice = function(theirDeviceIdentityKey) {
-    var sessions = this._sessionStore.getEndToEndSessions(
-        theirDeviceIdentityKey
-    );
-    return utils.keys(sessions);
+OlmDevice.prototype.getSessionIdsForDevice = async function (theirDeviceIdentityKey) {
+    if (this._sessionsInProgress[theirDeviceIdentityKey]) {
+        _logger2.default.log("waiting for session to be created");
+        try {
+            await this._sessionsInProgress[theirDeviceIdentityKey];
+        } catch (e) {
+            // if the session failed to be created, just fall through and
+            // return an empty result
+        }
+    }
+    let sessionIds;
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, sessions => {
+            sessionIds = Object.keys(sessions);
+        });
+    });
+
+    return sessionIds;
 };
 
 /**
  * Get the right olm session id for encrypting messages to the given identity key
  *
  * @param {string} theirDeviceIdentityKey Curve25519 identity key for the
  *     remote device
- * @return {string?}  session id, or null if no established session
+ * @param {boolean} nowait Don't wait for an in-progress session to complete.
+ *     This should only be set to true of the calling function is the function
+ *     that marked the session as being in-progress.
+ * @return {Promise<?string>}  session id, or null if no established session
  */
-OlmDevice.prototype.getSessionIdForDevice = function(theirDeviceIdentityKey) {
-    var sessionIds = this.getSessionIdsForDevice(theirDeviceIdentityKey);
-    if (sessionIds.length === 0) {
+OlmDevice.prototype.getSessionIdForDevice = async function (theirDeviceIdentityKey, nowait) {
+    const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait);
+
+    if (sessionInfos.length === 0) {
         return null;
     }
-    // Use the session with the lowest ID.
-    sessionIds.sort();
-    return sessionIds[0];
+    // Use the session that has most recently received a message
+    let idxOfBest = 0;
+    for (let i = 1; i < sessionInfos.length; i++) {
+        const thisSessInfo = sessionInfos[i];
+        const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs;
+
+        const bestSessInfo = sessionInfos[idxOfBest];
+        const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs;
+        if (thisLastReceived > bestLastReceived || thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId) {
+            idxOfBest = i;
+        }
+    }
+    return sessionInfos[idxOfBest].sessionId;
 };
 
 /**
  * Get information on the active Olm sessions for a device.
  * <p>
  * Returns an array, with an entry for each active session. The first entry in
  * the result will be the one used for outgoing messages. Each entry contains
  * the keys 'hasReceivedMessage' (true if the session has received an incoming
  * message and is therefore past the pre-key stage), and 'sessionId'.
  *
  * @param {string} deviceIdentityKey Curve25519 identity key for the device
+ * @param {boolean} nowait Don't wait for an in-progress session to complete.
+ *     This should only be set to true of the calling function is the function
+ *     that marked the session as being in-progress.
  * @return {Array.<{sessionId: string, hasReceivedMessage: Boolean}>}
  */
-OlmDevice.prototype.getSessionInfoForDevice = function(deviceIdentityKey) {
-    var sessionIds = this.getSessionIdsForDevice(deviceIdentityKey);
-    sessionIds.sort();
-
-    var info = [];
+OlmDevice.prototype.getSessionInfoForDevice = async function (deviceIdentityKey, nowait) {
+    if (this._sessionsInProgress[deviceIdentityKey] && !nowait) {
+        _logger2.default.log("waiting for session to be created");
+        try {
+            await this._sessionsInProgress[deviceIdentityKey];
+        } catch (e) {
+            // if the session failed to be created, then just fall through and
+            // return an empty result
+        }
+    }
+    const info = [];
 
-    function getSessionInfo(session) {
-        return {
-            hasReceivedMessage: session.has_received_message()
-        };
-    }
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, sessions => {
+            const sessionIds = Object.keys(sessions).sort();
+            for (const sessionId of sessionIds) {
+                this._unpickleSession(sessions[sessionId], sessInfo => {
+                    info.push({
+                        lastReceivedMessageTs: sessInfo.lastReceivedMessageTs,
+                        hasReceivedMessage: sessInfo.session.has_received_message(),
+                        sessionId: sessionId
+                    });
+                });
+            }
+        });
+    });
 
-    for (var i = 0; i < sessionIds.length; i++) {
-        var sessionId = sessionIds[i];
-        var res = this._getSession(deviceIdentityKey, sessionId, getSessionInfo);
-        res.sessionId = sessionId;
-        info.push(res);
-    }
     return info;
 };
 
 /**
  * Encrypt an outgoing message using an existing session
  *
  * @param {string} theirDeviceIdentityKey Curve25519 identity key for the
  *     remote device
  * @param {string} sessionId  the id of the active session
  * @param {string} payloadString  payload to be encrypted and sent
  *
- * @return {string} ciphertext
+ * @return {Promise<string>} ciphertext
  */
-OlmDevice.prototype.encryptMessage = function(
-    theirDeviceIdentityKey, sessionId, payloadString
-) {
-    var self = this;
+OlmDevice.prototype.encryptMessage = async function (theirDeviceIdentityKey, sessionId, payloadString) {
+    checkPayloadLength(payloadString);
 
-    if (payloadString === undefined) {
-        throw new Error("payloadString undefined");
-    }
-
-    return this._getSession(theirDeviceIdentityKey, sessionId, function(session) {
-        var res = session.encrypt(payloadString);
-        self._saveSession(theirDeviceIdentityKey, session);
-        return res;
+    let res;
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => {
+            res = sessionInfo.session.encrypt(payloadString);
+            this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
+        });
     });
+    return res;
 };
 
 /**
  * Decrypt an incoming message using an existing session
  *
  * @param {string} theirDeviceIdentityKey Curve25519 identity key for the
  *     remote device
  * @param {string} sessionId  the id of the active session
- * @param {number} message_type  message_type field from the received message
+ * @param {number} messageType  messageType field from the received message
  * @param {string} ciphertext base64-encoded body from the received message
  *
- * @return {string} decrypted payload.
+ * @return {Promise<string>} decrypted payload.
  */
-OlmDevice.prototype.decryptMessage = function(
-    theirDeviceIdentityKey, sessionId, message_type, ciphertext
-) {
-    var self = this;
-
-    return this._getSession(theirDeviceIdentityKey, sessionId, function(session) {
-        var payloadString = session.decrypt(message_type, ciphertext);
-        self._saveSession(theirDeviceIdentityKey, session);
-
-        return payloadString;
+OlmDevice.prototype.decryptMessage = async function (theirDeviceIdentityKey, sessionId, messageType, ciphertext) {
+    let payloadString;
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => {
+            payloadString = sessionInfo.session.decrypt(messageType, ciphertext);
+            sessionInfo.lastReceivedMessageTs = Date.now();
+            this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
+        });
     });
+    return payloadString;
 };
 
 /**
  * Determine if an incoming messages is a prekey message matching an existing session
  *
  * @param {string} theirDeviceIdentityKey Curve25519 identity key for the
  *     remote device
  * @param {string} sessionId  the id of the active session
- * @param {number} message_type  message_type field from the received message
+ * @param {number} messageType  messageType field from the received message
  * @param {string} ciphertext base64-encoded body from the received message
  *
- * @return {boolean} true if the received message is a prekey message which matches
+ * @return {Promise<boolean>} true if the received message is a prekey message which matches
  *    the given session.
  */
-OlmDevice.prototype.matchesSession = function(
-    theirDeviceIdentityKey, sessionId, message_type, ciphertext
-) {
-    if (message_type !== 0) {
+OlmDevice.prototype.matchesSession = async function (theirDeviceIdentityKey, sessionId, messageType, ciphertext) {
+    if (messageType !== 0) {
         return false;
     }
 
-    return this._getSession(theirDeviceIdentityKey, sessionId, function(session) {
-        return session.matches_inbound(ciphertext);
+    let matches;
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_SESSIONS], txn => {
+        this._getSession(theirDeviceIdentityKey, sessionId, txn, sessionInfo => {
+            matches = sessionInfo.session.matches_inbound(ciphertext);
+        });
     });
+    return matches;
 };
 
-
-
 // Outbound group session
 // ======================
 
 /**
  * store an OutboundGroupSession in _outboundGroupSessionStore
  *
  * @param {Olm.OutboundGroupSession} session
  * @private
  */
-OlmDevice.prototype._saveOutboundGroupSession = function(session) {
-    var pickledSession = session.pickle(this._pickleKey);
+OlmDevice.prototype._saveOutboundGroupSession = function (session) {
+    const pickledSession = session.pickle(this._pickleKey);
     this._outboundGroupSessionStore[session.session_id()] = pickledSession;
 };
 
-
 /**
  * extract an OutboundGroupSession from _outboundGroupSessionStore and call the
  * given function
  *
  * @param {string} sessionId
  * @param {function} func
  * @return {object} result of func
  * @private
  */
-OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
-    var pickled = this._outboundGroupSessionStore[sessionId];
-    if (pickled === null) {
+OlmDevice.prototype._getOutboundGroupSession = function (sessionId, func) {
+    const pickled = this._outboundGroupSessionStore[sessionId];
+    if (pickled === undefined) {
         throw new Error("Unknown outbound group session " + sessionId);
     }
 
-    var session = new Olm.OutboundGroupSession();
+    const session = new global.Olm.OutboundGroupSession();
     try {
         session.unpickle(this._pickleKey, pickled);
         return func(session);
     } finally {
         session.free();
     }
 };
 
-
 /**
  * Generate a new outbound group session
  *
  * @return {string} sessionId for the outbound session.
  */
-OlmDevice.prototype.createOutboundGroupSession = function() {
-    var session = new Olm.OutboundGroupSession();
+OlmDevice.prototype.createOutboundGroupSession = function () {
+    const session = new global.Olm.OutboundGroupSession();
     try {
         session.create();
         this._saveOutboundGroupSession(session);
         return session.session_id();
     } finally {
         session.free();
     }
 };
 
-
 /**
  * Encrypt an outgoing message with an outbound group session
  *
  * @param {string} sessionId  the id of the outboundgroupsession
  * @param {string} payloadString  payload to be encrypted and sent
  *
  * @return {string} ciphertext
  */
-OlmDevice.prototype.encryptGroupMessage = function(sessionId, payloadString) {
-    var self = this;
+OlmDevice.prototype.encryptGroupMessage = function (sessionId, payloadString) {
+    const self = this;
 
-    return this._getOutboundGroupSession(sessionId, function(session) {
-        var res = session.encrypt(payloadString);
+    checkPayloadLength(payloadString);
+
+    return this._getOutboundGroupSession(sessionId, function (session) {
+        const res = session.encrypt(payloadString);
         self._saveOutboundGroupSession(session);
         return res;
     });
 };
 
 /**
  * Get the session keys for an outbound group session
  *
  * @param {string} sessionId  the id of the outbound group session
  *
  * @return {{chain_index: number, key: string}} current chain index, and
  *     base64-encoded secret key.
  */
-OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) {
-    return this._getOutboundGroupSession(sessionId, function(session) {
+OlmDevice.prototype.getOutboundGroupSessionKey = function (sessionId) {
+    return this._getOutboundGroupSession(sessionId, function (session) {
         return {
             chain_index: session.message_index(),
-            key: session.session_key(),
+            key: session.session_key()
         };
     });
 };
 
-
 // Inbound group session
 // =====================
 
 /**
- * store an InboundGroupSession in the session store
+ * data stored in the session store about an inbound group session
  *
- * @param {string} roomId
- * @param {string} senderCurve25519Key
- * @param {string} sessionId
- * @param {Olm.InboundGroupSession} session
- * @param {object} keysClaimed Other keys the sender claims.
- * @private
+ * @typedef {Object} InboundGroupSessionData
+ * @property {string} room_Id
+ * @property {string} session   pickled Olm.InboundGroupSession
+ * @property {Object<string, string>} keysClaimed
+ * @property {Array<string>} forwardingCurve25519KeyChain  Devices involved in forwarding
+ *     this session to us (normally empty).
  */
-OlmDevice.prototype._saveInboundGroupSession = function(
-    roomId, senderCurve25519Key, sessionId, session, keysClaimed
-) {
-    var r = {
-        room_id: roomId,
-        session: session.pickle(this._pickleKey),
-        keysClaimed: keysClaimed,
-    };
 
-    this._sessionStore.storeEndToEndInboundGroupSession(
-        senderCurve25519Key, sessionId, JSON.stringify(r)
-    );
+/**
+ * Unpickle a session from a sessionData object and invoke the given function.
+ * The session is valid only until func returns.
+ *
+ * @param {Object} sessionData Object describing the session.
+ * @param {function(Olm.InboundGroupSession)} func Invoked with the unpickled session
+ * @return {*} result of func
+ */
+OlmDevice.prototype._unpickleInboundGroupSession = function (sessionData, func) {
+    const session = new global.Olm.InboundGroupSession();
+    try {
+        session.unpickle(this._pickleKey, sessionData.session);
+        return func(session);
+    } finally {
+        session.free();
+    }
 };
 
 /**
- * extract an InboundGroupSession from the session store and call the given function
+ * extract an InboundGroupSession from the crypto store and call the given function
  *
- * @param {string} roomId
+ * @param {string} roomId The room ID to extract the session for, or null to fetch
+ *     sessions for any room.
  * @param {string} senderKey
  * @param {string} sessionId
- * @param {function(Olm.InboundGroupSession, Object<string, string>): T} func
- *   function to call. Second argument is the map of keys claimed by the session.
- *
- * @return {null} the sessionId is unknown
- *
- * @return {T} result of func
+ * @param {*} txn Opaque transaction object from cryptoStore.doTxn()
+ * @param {function(Olm.InboundGroupSession, InboundGroupSessionData)} func
+ *   function to call.
  *
  * @private
- * @template {T}
  */
-OlmDevice.prototype._getInboundGroupSession = function(
-    roomId, senderKey, sessionId, func
-) {
-    var r = this._sessionStore.getEndToEndInboundGroupSession(
-        senderKey, sessionId
-    );
-
-    if (r === null) {
-        return null;
-    }
-
-    r = JSON.parse(r);
+OlmDevice.prototype._getInboundGroupSession = function (roomId, senderKey, sessionId, txn, func) {
+    this._cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, sessionData => {
+        if (sessionData === null) {
+            func(null);
+            return;
+        }
 
-    // check that the room id matches the original one for the session. This stops
-    // the HS pretending a message was targeting a different room.
-    if (roomId !== r.room_id) {
-        throw new Error(
-            "Mismatched room_id for inbound group session (expected " + r.room_id +
-                ", was " + roomId + ")"
-        );
-    }
+        // if we were given a room ID, check that the it matches the original one for the session. This stops
+        // the HS pretending a message was targeting a different room.
+        if (roomId !== null && roomId !== sessionData.room_id) {
+            throw new Error("Mismatched room_id for inbound group session (expected " + sessionData.room_id + ", was " + roomId + ")");
+        }
 
-    var session = new Olm.InboundGroupSession();
-    try {
-        session.unpickle(this._pickleKey, r.session);
-        return func(session, r.keysClaimed || {});
-    } finally {
-        session.free();
-    }
+        this._unpickleInboundGroupSession(sessionData, session => {
+            func(session, sessionData);
+        });
+    });
 };
 
 /**
  * Add an inbound group session to the session store
  *
  * @param {string} roomId     room in which this session will be used
  * @param {string} senderKey  base64-encoded curve25519 key of the sender
+ * @param {Array<string>} forwardingCurve25519KeyChain  Devices involved in forwarding
+ *     this session to us.
  * @param {string} sessionId  session identifier
  * @param {string} sessionKey base64-encoded secret key
  * @param {Object<string, string>} keysClaimed Other keys the sender claims.
+ * @param {boolean} exportFormat true if the megolm keys are in export format
+ *    (ie, they lack an ed25519 signature)
  */
-OlmDevice.prototype.addInboundGroupSession = function(
-    roomId, senderKey, sessionId, sessionKey, keysClaimed
-) {
-    var self = this;
-
-    /* if we already have this session, consider updating it */
-    function updateSession(session) {
-        console.log("Update for megolm session " + senderKey + "/" + sessionId);
-        // for now we just ignore updates. TODO: implement something here
-
-        return true;
-    }
-
-    var r = this._getInboundGroupSession(
-        roomId, senderKey, sessionId, updateSession
-    );
+OlmDevice.prototype.addInboundGroupSession = async function (roomId, senderKey, forwardingCurve25519KeyChain, sessionId, sessionKey, keysClaimed, exportFormat) {
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_INBOUND_GROUP_SESSIONS], txn => {
+        /* if we already have this session, consider updating it */
+        this._getInboundGroupSession(roomId, senderKey, sessionId, txn, (existingSession, existingSessionData) => {
+            // new session.
+            const session = new global.Olm.InboundGroupSession();
+            try {
+                if (exportFormat) {
+                    session.import_session(sessionKey);
+                } else {
+                    session.create(sessionKey);
+                }
+                if (sessionId != session.session_id()) {
+                    throw new Error("Mismatched group session ID from senderKey: " + senderKey);
+                }
 
-    if (r !== null) {
-        return;
-    }
+                if (existingSession) {
+                    _logger2.default.log("Update for megolm session " + senderKey + "/" + sessionId);
+                    if (existingSession.first_known_index() <= session.first_known_index()) {
+                        // existing session has lower index (i.e. can
+                        // decrypt more), so keep it
+                        _logger2.default.log("Keeping existing session");
+                        return;
+                    }
+                }
 
-    // new session.
-    var session = new Olm.InboundGroupSession();
-    try {
-        session.create(sessionKey);
-        if (sessionId != session.session_id()) {
-            throw new Error(
-                "Mismatched group session ID from senderKey: " + senderKey
-            );
-        }
-        self._saveInboundGroupSession(
-            roomId, senderKey, sessionId, session, keysClaimed
-        );
-    } finally {
-        session.free();
-    }
+                const sessionData = {
+                    room_id: roomId,
+                    session: session.pickle(this._pickleKey),
+                    keysClaimed: keysClaimed,
+                    forwardingCurve25519KeyChain: forwardingCurve25519KeyChain
+                };
+
+                this._cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn);
+            } finally {
+                session.free();
+            }
+        });
+    });
 };
 
 /**
  * Decrypt a received message with an inbound group session
  *
  * @param {string} roomId    room in which the message was received
  * @param {string} senderKey base64-encoded curve25519 key of the sender
  * @param {string} sessionId session identifier
  * @param {string} body      base64-encoded body of the encrypted message
+ * @param {string} eventId   ID of the event being decrypted
+ * @param {Number} timestamp timestamp of the event being decrypted
  *
  * @return {null} the sessionId is unknown
  *
- * @return {{result: string, keysProved: Object<string, string>, keysClaimed:
- *    Object<string, string>}} result
+ * @return {Promise<{result: string, senderKey: string,
+ *    forwardingCurve25519KeyChain: Array<string>,
+ *    keysClaimed: Object<string, string>}>}
  */
-OlmDevice.prototype.decryptGroupMessage = function(
-    roomId, senderKey, sessionId, body
-) {
-    var self = this;
+OlmDevice.prototype.decryptGroupMessage = async function (roomId, senderKey, sessionId, body, eventId, timestamp) {
+    let result;
 
-    function decrypt(session, keysClaimed) {
-        var res = session.decrypt(body);
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_INBOUND_GROUP_SESSIONS], txn => {
+        this._getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => {
+            if (session === null) {
+                result = null;
+                return;
+            }
+            const res = session.decrypt(body);
 
-        var plaintext = res.plaintext;
-        if (plaintext === undefined) {
-            // Compatibility for older olm versions.
-            plaintext = res;
-        } else {
-            // Check if we have seen this message index before to detect replay attacks.
-            var messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index;
-            if (messageIndexKey in self._inboundGroupSessionMessageIndexes) {
-                throw new Error(
-                    "Duplicate message index, possible replay attack: " +
-                    messageIndexKey
-                );
+            let plaintext = res.plaintext;
+            if (plaintext === undefined) {
+                // Compatibility for older olm versions.
+                plaintext = res;
+            } else {
+                // Check if we have seen this message index before to detect replay attacks.
+                // If the event ID and timestamp are specified, and the match the event ID
+                // and timestamp from the last time we used this message index, then we
+                // don't consider it a replay attack.
+                const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index;
+                if (messageIndexKey in this._inboundGroupSessionMessageIndexes) {
+                    const msgInfo = this._inboundGroupSessionMessageIndexes[messageIndexKey];
+                    if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) {
+                        throw new Error("Duplicate message index, possible replay attack: " + messageIndexKey);
+                    }
+                }
+                this._inboundGroupSessionMessageIndexes[messageIndexKey] = {
+                    id: eventId,
+                    timestamp: timestamp
+                };
             }
-            self._inboundGroupSessionMessageIndexes[messageIndexKey] = true;
-        }
 
-        // the sender must have had the senderKey to persuade us to save the
-        // session.
-        var keysProved = {curve25519: senderKey};
+            sessionData.session = session.pickle(this._pickleKey);
+            this._cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn);
+            result = {
+                result: plaintext,
+                keysClaimed: sessionData.keysClaimed || {},
+                senderKey: senderKey,
+                forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || []
+            };
+        });
+    });
 
-        self._saveInboundGroupSession(
-            roomId, senderKey, sessionId, session, keysClaimed
-        );
-        return {
-            result: plaintext,
-            keysClaimed: keysClaimed,
-            keysProved: keysProved,
-        };
-    }
-
-    return this._getInboundGroupSession(
-        roomId, senderKey, sessionId, decrypt
-    );
+    return result;
 };
 
+/**
+ * Determine if we have the keys for a given megolm session
+ *
+ * @param {string} roomId    room in which the message was received
+ * @param {string} senderKey base64-encoded curve25519 key of the sender
+ * @param {sring} sessionId session identifier
+ *
+ * @returns {Promise<boolean>} true if we have the keys to this session
+ */
+OlmDevice.prototype.hasInboundSessionKeys = async function (roomId, senderKey, sessionId) {
+    let result;
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_INBOUND_GROUP_SESSIONS], txn => {
+        this._cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, sessionData => {
+            if (sessionData === null) {
+                result = false;
+                return;
+            }
+
+            if (roomId !== sessionData.room_id) {
+                _logger2.default.warn(`requested keys for inbound group session ${senderKey}|` + `${sessionId}, with incorrect room_id ` + `(expected ${sessionData.room_id}, ` + `was ${roomId})`);
+                result = false;
+            } else {
+                result = true;
+            }
+        });
+    });
+
+    return result;
+};
+
+/**
+ * Extract the keys to a given megolm session, for sharing
+ *
+ * @param {string} roomId    room in which the message was received
+ * @param {string} senderKey base64-encoded curve25519 key of the sender
+ * @param {string} sessionId session identifier
+ * @param {integer} chainIndex The chain index at which to export the session.
+ *     If omitted, export at the first index we know about.
+ *
+ * @returns {Promise<{chain_index: number, key: string,
+ *        forwarding_curve25519_key_chain: Array<string>,
+ *        sender_claimed_ed25519_key: string
+ *    }>}
+ *    details of the session key. The key is a base64-encoded megolm key in
+ *    export format.
+ *
+ * @throws Error If the given chain index could not be obtained from the known
+ *     index (ie. the given chain index is before the first we have).
+ */
+OlmDevice.prototype.getInboundGroupSessionKey = async function (roomId, senderKey, sessionId, chainIndex) {
+    let result;
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_INBOUND_GROUP_SESSIONS], txn => {
+        this._getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => {
+            if (session === null) {
+                result = null;
+                return;
+            }
+
+            if (chainIndex === undefined) {
+                chainIndex = session.first_known_index();
+            }
+
+            const exportedSession = session.export_session(chainIndex);
+
+            const claimedKeys = sessionData.keysClaimed || {};
+            const senderEd25519Key = claimedKeys.ed25519 || null;
+
+            result = {
+                "chain_index": chainIndex,
+                "key": exportedSession,
+                "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [],
+                "sender_claimed_ed25519_key": senderEd25519Key
+            };
+        });
+    });
+
+    return result;
+};
+
+/**
+ * Export an inbound group session
+ *
+ * @param {string} senderKey base64-encoded curve25519 key of the sender
+ * @param {string} sessionId session identifier
+ * @param {string} sessionData The session object from the store
+ * @return {module:crypto/OlmDevice.MegolmSessionData} exported session data
+ */
+OlmDevice.prototype.exportInboundGroupSession = function (senderKey, sessionId, sessionData) {
+    return this._unpickleInboundGroupSession(sessionData, session => {
+        const messageIndex = session.first_known_index();
+
+        return {
+            "sender_key": senderKey,
+            "sender_claimed_keys": sessionData.keysClaimed,
+            "room_id": sessionData.room_id,
+            "session_id": sessionId,
+            "session_key": session.export_session(messageIndex),
+            "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [],
+            "first_known_index": session.first_known_index()
+        };
+    });
+};
 
 // Utilities
 // =========
 
 /**
  * Verify an ed25519 signature.
  *
  * @param {string} key ed25519 key
  * @param {string} message message which was signed
  * @param {string} signature base64-encoded signature to be checked
  *
  * @raises {Error} if there is a problem with the verification. If the key was
  * too small then the message will be "OLM.INVALID_BASE64". If the signature
  * was invalid then the message will be "OLM.BAD_MESSAGE_MAC".
  */
-OlmDevice.prototype.verifySignature = function(
-    key, message, signature
-) {
-    this._getUtility(function(util) {
+OlmDevice.prototype.verifySignature = function (key, message, signature) {
+    this._getUtility(function (util) {
         util.ed25519_verify(key, message, signature);
     });
 };
 
 /** */
-module.exports = OlmDevice;
+module.exports = OlmDevice;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js
@@ -0,0 +1,438 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _utils = require('../utils');
+
+var _utils2 = _interopRequireDefault(_utils);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * Internal module. Management of outgoing room key requests.
+ *
+ * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ
+ * for draft documentation on what we're supposed to be implementing here.
+ *
+ * @module
+ */
+
+// delay between deciding we want some keys, and sending out the request, to
+// allow for (a) it turning up anyway, (b) grouping requests together
+const SEND_KEY_REQUESTS_DELAY_MS = 500;
+
+/** possible states for a room key request
+ *
+ * The state machine looks like:
+ *
+ *     |         (cancellation sent)
+ *     | .-------------------------------------------------.
+ *     | |                                                 |
+ *     V V       (cancellation requested)                  |
+ *   UNSENT  -----------------------------+                |
+ *     |                                  |                |
+ *     |                                  |                |
+ *     | (send successful)                |  CANCELLATION_PENDING_AND_WILL_RESEND
+ *     V                                  |                Λ
+ *    SENT                                |                |
+ *     |--------------------------------  |  --------------'
+ *     |                                  |  (cancellation requested with intent
+ *     |                                  |   to resend the original request)
+ *     |                                  |
+ *     | (cancellation requested)         |
+ *     V                                  |
+ * CANCELLATION_PENDING                   |
+ *     |                                  |
+ *     | (cancellation sent)              |
+ *     V                                  |
+ * (deleted)  <---------------------------+
+ *
+ * @enum {number}
+ */
+/*
+Copyright 2017 Vector Creations Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+const ROOM_KEY_REQUEST_STATES = {
+    /** request not yet sent */
+    UNSENT: 0,
+
+    /** request sent, awaiting reply */
+    SENT: 1,
+
+    /** reply received, cancellation not yet sent */
+    CANCELLATION_PENDING: 2,
+
+    /**
+     * Cancellation not yet sent and will transition to UNSENT instead of
+     * being deleted once the cancellation has been sent.
+     */
+    CANCELLATION_PENDING_AND_WILL_RESEND: 3
+};
+
+class OutgoingRoomKeyRequestManager {
+    constructor(baseApis, deviceId, cryptoStore) {
+        this._baseApis = baseApis;
+        this._deviceId = deviceId;
+        this._cryptoStore = cryptoStore;
+
+        // handle for the delayed call to _sendOutgoingRoomKeyRequests. Non-null
+        // if the callback has been set, or if it is still running.
+        this._sendOutgoingRoomKeyRequestsTimer = null;
+
+        // sanity check to ensure that we don't end up with two concurrent runs
+        // of _sendOutgoingRoomKeyRequests
+        this._sendOutgoingRoomKeyRequestsRunning = false;
+
+        this._clientRunning = false;
+    }
+
+    /**
+     * Called when the client is started. Sets background processes running.
+     */
+    start() {
+        this._clientRunning = true;
+
+        // set the timer going, to handle any requests which didn't get sent
+        // on the previous run of the client.
+        this._startTimer();
+    }
+
+    /**
+     * Called when the client is stopped. Stops any running background processes.
+     */
+    stop() {
+        _logger2.default.log('stopping OutgoingRoomKeyRequestManager');
+        // stop the timer on the next run
+        this._clientRunning = false;
+    }
+
+    /**
+     * Send off a room key request, if we haven't already done so.
+     *
+     * The `requestBody` is compared (with a deep-equality check) against
+     * previous queued or sent requests and if it matches, no change is made.
+     * Otherwise, a request is added to the pending list, and a job is started
+     * in the background to send it.
+     *
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     * @param {Array<{userId: string, deviceId: string}>} recipients
+     * @param {boolean} resend whether to resend the key request if there is
+     *    already one
+     *
+     * @returns {Promise} resolves when the request has been added to the
+     *    pending list (or we have established that a similar request already
+     *    exists)
+     */
+    async sendRoomKeyRequest(requestBody, recipients, resend = false) {
+        const req = await this._cryptoStore.getOutgoingRoomKeyRequest(requestBody);
+        if (!req) {
+            await this._cryptoStore.getOrAddOutgoingRoomKeyRequest({
+                requestBody: requestBody,
+                recipients: recipients,
+                requestId: this._baseApis.makeTxnId(),
+                state: ROOM_KEY_REQUEST_STATES.UNSENT
+            });
+        } else {
+            switch (req.state) {
+                case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND:
+                case ROOM_KEY_REQUEST_STATES.UNSENT:
+                    // nothing to do here, since we're going to send a request anyways
+                    return;
+
+                case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING:
+                    {
+                        // existing request is about to be cancelled.  If we want to
+                        // resend, then change the state so that it resends after
+                        // cancelling.  Otherwise, just cancel the cancellation.
+                        const state = resend ? ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND : ROOM_KEY_REQUEST_STATES.SENT;
+                        await this._cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, {
+                            state,
+                            cancellationTxnId: this._baseApis.makeTxnId()
+                        });
+                        break;
+                    }
+                case ROOM_KEY_REQUEST_STATES.SENT:
+                    {
+                        // a request has already been sent.  If we don't want to
+                        // resend, then do nothing.  If we do want to, then cancel the
+                        // existing request and send a new one.
+                        if (resend) {
+                            const state = ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND;
+                            const updatedReq = await this._cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.SENT, {
+                                state,
+                                cancellationTxnId: this._baseApis.makeTxnId(),
+                                // need to use a new transaction ID so that
+                                // the request gets sent
+                                requestTxnId: this._baseApis.makeTxnId()
+                            });
+                            if (!updatedReq) {
+                                // updateOutgoingRoomKeyRequest couldn't find the request
+                                // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
+                                // raced with another tab to mark the request cancelled.
+                                // Try again, to make sure the request is resent.
+                                return await this.sendRoomKeyRequest(requestBody, recipients, resend);
+                            }
+
+                            // We don't want to wait for the timer, so we send it
+                            // immediately. (We might actually end up racing with the timer,
+                            // but that's ok: even if we make the request twice, we'll do it
+                            // with the same transaction_id, so only one message will get
+                            // sent).
+                            //
+                            // (We also don't want to wait for the response from the server
+                            // here, as it will slow down processing of received keys if we
+                            // do.)
+                            try {
+                                await this._sendOutgoingRoomKeyRequestCancellation(updatedReq, true);
+                            } catch (e) {
+                                _logger2.default.error("Error sending room key request cancellation;" + " will retry later.", e);
+                            }
+                            // The request has transitioned from
+                            // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We
+                            // still need to resend the request which is now UNSENT, so
+                            // start the timer if it isn't already started.
+                        }
+                        break;
+                    }
+                default:
+                    throw new Error('unhandled state: ' + req.state);
+            }
+        }
+        // some of the branches require the timer to be started.  Just start it
+        // all the time, because it doesn't hurt to start it.
+        this._startTimer();
+    }
+
+    /**
+     * Cancel room key requests, if any match the given requestBody
+     *
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     *
+     * @returns {Promise} resolves when the request has been updated in our
+     *    pending list.
+     */
+    cancelRoomKeyRequest(requestBody) {
+        return this._cryptoStore.getOutgoingRoomKeyRequest(requestBody).then(req => {
+            if (!req) {
+                // no request was made for this key
+                return;
+            }
+            switch (req.state) {
+                case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING:
+                case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND:
+                    // nothing to do here
+                    return;
+
+                case ROOM_KEY_REQUEST_STATES.UNSENT:
+                    // just delete it
+
+                    // FIXME: ghahah we may have attempted to send it, and
+                    // not yet got a successful response. So the server
+                    // may have seen it, so we still need to send a cancellation
+                    // in that case :/
+
+                    _logger2.default.log('deleting unnecessary room key request for ' + stringifyRequestBody(requestBody));
+                    return this._cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT);
+
+                case ROOM_KEY_REQUEST_STATES.SENT:
+                    {
+                        // send a cancellation.
+                        return this._cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.SENT, {
+                            state: ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING,
+                            cancellationTxnId: this._baseApis.makeTxnId()
+                        }).then(updatedReq => {
+                            if (!updatedReq) {
+                                // updateOutgoingRoomKeyRequest couldn't find the
+                                // request in state ROOM_KEY_REQUEST_STATES.SENT,
+                                // so we must have raced with another tab to mark
+                                // the request cancelled. There is no point in
+                                // sending another cancellation since the other tab
+                                // will do it.
+                                _logger2.default.log('Tried to cancel room key request for ' + stringifyRequestBody(requestBody) + ' but it was already cancelled in another tab');
+                                return;
+                            }
+
+                            // We don't want to wait for the timer, so we send it
+                            // immediately. (We might actually end up racing with the timer,
+                            // but that's ok: even if we make the request twice, we'll do it
+                            // with the same transaction_id, so only one message will get
+                            // sent).
+                            //
+                            // (We also don't want to wait for the response from the server
+                            // here, as it will slow down processing of received keys if we
+                            // do.)
+                            this._sendOutgoingRoomKeyRequestCancellation(updatedReq).catch(e => {
+                                _logger2.default.error("Error sending room key request cancellation;" + " will retry later.", e);
+                                this._startTimer();
+                            });
+                        });
+                    }
+                default:
+                    throw new Error('unhandled state: ' + req.state);
+            }
+        });
+    }
+
+    /**
+     * Look for room key requests by target device and state
+     *
+     * @param {string} userId Target user ID
+     * @param {string} deviceId Target device ID
+     *
+     * @return {Promise} resolves to a list of all the
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}
+     */
+    getOutgoingSentRoomKeyRequest(userId, deviceId) {
+        return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT]);
+    }
+
+    // start the background timer to send queued requests, if the timer isn't
+    // already running
+    _startTimer() {
+        if (this._sendOutgoingRoomKeyRequestsTimer) {
+            return;
+        }
+
+        const startSendingOutgoingRoomKeyRequests = () => {
+            if (this._sendOutgoingRoomKeyRequestsRunning) {
+                throw new Error("RoomKeyRequestSend already in progress!");
+            }
+            this._sendOutgoingRoomKeyRequestsRunning = true;
+
+            this._sendOutgoingRoomKeyRequests().finally(() => {
+                this._sendOutgoingRoomKeyRequestsRunning = false;
+            }).catch(e => {
+                // this should only happen if there is an indexeddb error,
+                // in which case we're a bit stuffed anyway.
+                _logger2.default.warn(`error in OutgoingRoomKeyRequestManager: ${e}`);
+            });
+        };
+
+        this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout(startSendingOutgoingRoomKeyRequests, SEND_KEY_REQUESTS_DELAY_MS);
+    }
+
+    // look for and send any queued requests. Runs itself recursively until
+    // there are no more requests, or there is an error (in which case, the
+    // timer will be restarted before the promise resolves).
+    _sendOutgoingRoomKeyRequests() {
+        if (!this._clientRunning) {
+            this._sendOutgoingRoomKeyRequestsTimer = null;
+            return _bluebird2.default.resolve();
+        }
+
+        _logger2.default.log("Looking for queued outgoing room key requests");
+
+        return this._cryptoStore.getOutgoingRoomKeyRequestByState([ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, ROOM_KEY_REQUEST_STATES.UNSENT]).then(req => {
+            if (!req) {
+                _logger2.default.log("No more outgoing room key requests");
+                this._sendOutgoingRoomKeyRequestsTimer = null;
+                return;
+            }
+
+            let prom;
+            switch (req.state) {
+                case ROOM_KEY_REQUEST_STATES.UNSENT:
+                    prom = this._sendOutgoingRoomKeyRequest(req);
+                    break;
+                case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING:
+                    prom = this._sendOutgoingRoomKeyRequestCancellation(req);
+                    break;
+                case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND:
+                    prom = this._sendOutgoingRoomKeyRequestCancellation(req, true);
+                    break;
+            }
+
+            return prom.then(() => {
+                // go around the loop again
+                return this._sendOutgoingRoomKeyRequests();
+            }).catch(e => {
+                _logger2.default.error("Error sending room key request; will retry later.", e);
+                this._sendOutgoingRoomKeyRequestsTimer = null;
+                this._startTimer();
+            });
+        });
+    }
+
+    // given a RoomKeyRequest, send it and update the request record
+    _sendOutgoingRoomKeyRequest(req) {
+        _logger2.default.log(`Requesting keys for ${stringifyRequestBody(req.requestBody)}` + ` from ${stringifyRecipientList(req.recipients)}` + `(id ${req.requestId})`);
+
+        const requestMessage = {
+            action: "request",
+            requesting_device_id: this._deviceId,
+            request_id: req.requestId,
+            body: req.requestBody
+        };
+
+        return this._sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => {
+            return this._cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, { state: ROOM_KEY_REQUEST_STATES.SENT });
+        });
+    }
+
+    // Given a RoomKeyRequest, cancel it and delete the request record unless
+    // andResend is set, in which case transition to UNSENT.
+    _sendOutgoingRoomKeyRequestCancellation(req, andResend) {
+        _logger2.default.log(`Sending cancellation for key request for ` + `${stringifyRequestBody(req.requestBody)} to ` + `${stringifyRecipientList(req.recipients)} ` + `(cancellation id ${req.cancellationTxnId})`);
+
+        const requestMessage = {
+            action: "request_cancellation",
+            requesting_device_id: this._deviceId,
+            request_id: req.requestId
+        };
+
+        return this._sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => {
+            if (andResend) {
+                // We want to resend, so transition to UNSENT
+                return this._cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, { state: ROOM_KEY_REQUEST_STATES.UNSENT });
+            }
+            return this._cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING);
+        });
+    }
+
+    // send a RoomKeyRequest to a list of recipients
+    _sendMessageToDevices(message, recipients, txnId) {
+        const contentMap = {};
+        for (const recip of recipients) {
+            if (!contentMap[recip.userId]) {
+                contentMap[recip.userId] = {};
+            }
+            contentMap[recip.userId][recip.deviceId] = message;
+        }
+
+        return this._baseApis.sendToDevice('m.room_key_request', contentMap, txnId);
+    }
+}
+
+exports.default = OutgoingRoomKeyRequestManager;
+function stringifyRequestBody(requestBody) {
+    // we assume that the request is for megolm keys, which are identified by
+    // room id and session id
+    return requestBody.room_id + " / " + requestBody.session_id;
+}
+
+function stringifyRecipientList(recipients) {
+    return '[' + _utils2.default.map(recipients, r => `${r.userId}:${r.deviceId}`).join(",") + ']';
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/RoomList.js
@@ -0,0 +1,70 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _indexeddbCryptoStore = require('./store/indexeddb-crypto-store');
+
+var _indexeddbCryptoStore2 = _interopRequireDefault(_indexeddbCryptoStore);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * @alias module:crypto/RoomList
+ */
+class RoomList {
+    constructor(cryptoStore) {
+        this._cryptoStore = cryptoStore;
+
+        // Object of roomId -> room e2e info object (body of the m.room.encryption event)
+        this._roomEncryption = {};
+    }
+
+    async init() {
+        await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ROOMS], txn => {
+            this._cryptoStore.getEndToEndRooms(txn, result => {
+                this._roomEncryption = result;
+            });
+        });
+    }
+
+    getRoomEncryption(roomId) {
+        return this._roomEncryption[roomId] || null;
+    }
+
+    isRoomEncrypted(roomId) {
+        return Boolean(this.getRoomEncryption(roomId));
+    }
+
+    async setRoomEncryption(roomId, roomInfo) {
+        // important that this happens before calling into the store
+        // as it prevents the Crypto::setRoomEncryption from calling
+        // this twice for consecutive m.room.encryption events
+        this._roomEncryption[roomId] = roomInfo;
+        await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_ROOMS], txn => {
+            this._cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn);
+        });
+    }
+}
+exports.default = RoomList; /*
+                            Copyright 2018, 2019 New Vector Ltd
+                            
+                            Licensed under the Apache License, Version 2.0 (the "License");
+                            you may not use this file except in compliance with the License.
+                            You may obtain a copy of the License at
+                            
+                                http://www.apache.org/licenses/LICENSE-2.0
+                            
+                            Unless required by applicable law or agreed to in writing, software
+                            distributed under the License is distributed on an "AS IS" BASIS,
+                            WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                            See the License for the specific language governing permissions and
+                            limitations under the License.
+                            */
+
+/**
+ * @module crypto/RoomList
+ *
+ * Manages the list of encrypted rooms
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/base.js
@@ -1,167 +1,247 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.UnknownDeviceError = exports.DecryptionError = exports.DecryptionAlgorithm = exports.EncryptionAlgorithm = exports.DECRYPTION_CLASSES = exports.ENCRYPTION_CLASSES = undefined;
+exports.registerAlgorithm = registerAlgorithm;
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * map of registered encryption algorithm classes. A map from string to {@link
+ * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class
+ *
+ * @type {Object.<string, function(new: module:crypto/algorithms/base.EncryptionAlgorithm)>}
+ */
+const ENCRYPTION_CLASSES = exports.ENCRYPTION_CLASSES = {};
+
+/**
+ * map of registered encryption algorithm classes. Map from string to {@link
+ * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} class
+ *
+ * @type {Object.<string, function(new: module:crypto/algorithms/base.DecryptionAlgorithm)>}
+ */
 /*
 Copyright 2016 OpenMarket Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
-"use strict";
 
 /**
  * Internal module. Defines the base classes of the encryption implementations
  *
- * @module crypto/algorithms/base
- */
-var utils = require("../../utils");
-
-/**
- * map of registered encryption algorithm classes. A map from string to {@link
- * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class
- *
- * @type {Object.<string, function(new: module:crypto/algorithms/base.EncryptionAlgorithm)>}
+ * @module
  */
-module.exports.ENCRYPTION_CLASSES = {};
 
-/**
- * map of registered encryption algorithm classes. Map from string to {@link
- * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} class
- *
- * @type {Object.<string, function(new: module:crypto/algorithms/base.DecryptionAlgorithm)>}
- */
-module.exports.DECRYPTION_CLASSES = {};
+const DECRYPTION_CLASSES = exports.DECRYPTION_CLASSES = {};
 
 /**
  * base type for encryption implementations
  *
- * @constructor
  * @alias module:crypto/algorithms/base.EncryptionAlgorithm
  *
  * @param {object} params parameters
  * @param {string} params.userId  The UserID for the local user
  * @param {string} params.deviceId The identifier for this device.
  * @param {module:crypto} params.crypto crypto core
  * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
  * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
  * @param {string} params.roomId  The ID of the room we will be sending to
  * @param {object} params.config  The body of the m.room.encryption event
  */
-var EncryptionAlgorithm = function(params) {
-    this._userId = params.userId;
-    this._deviceId = params.deviceId;
-    this._crypto = params.crypto;
-    this._olmDevice = params.olmDevice;
-    this._baseApis = params.baseApis;
-    this._roomId = params.roomId;
-};
-/** */
-module.exports.EncryptionAlgorithm = EncryptionAlgorithm;
+class EncryptionAlgorithm {
+    constructor(params) {
+        this._userId = params.userId;
+        this._deviceId = params.deviceId;
+        this._crypto = params.crypto;
+        this._olmDevice = params.olmDevice;
+        this._baseApis = params.baseApis;
+        this._roomId = params.roomId;
+    }
 
-/**
- * Encrypt a message event
- *
- * @method module:crypto/algorithms/base.EncryptionAlgorithm#encryptMessage
- * @abstract
- *
- * @param {module:models/room} room
- * @param {string} eventType
- * @param {object} plaintext event content
- *
- * @return {module:client.Promise} Promise which resolves to the new event body
- */
+    /**
+     * Encrypt a message event
+     *
+     * @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage
+     * @abstract
+     *
+     * @param {module:models/room} room
+     * @param {string} eventType
+     * @param {object} plaintext event content
+     *
+     * @return {module:client.Promise} Promise which resolves to the new event body
+     */
 
-/**
- * Called when the membership of a member of the room changes.
- *
- * @param {module:models/event.MatrixEvent} event  event causing the change
- * @param {module:models/room-member} member  user whose membership changed
- * @param {string=} oldMembership  previous membership
- */
-EncryptionAlgorithm.prototype.onRoomMembership = function(
-    event, member, oldMembership
-) {};
+    /**
+     * Called when the membership of a member of the room changes.
+     *
+     * @param {module:models/event.MatrixEvent} event  event causing the change
+     * @param {module:models/room-member} member  user whose membership changed
+     * @param {string=} oldMembership  previous membership
+     * @public
+     */
+    onRoomMembership(event, member, oldMembership) {}
+}
+exports.EncryptionAlgorithm = EncryptionAlgorithm; // https://github.com/jsdoc3/jsdoc/issues/1272
 
 /**
  * base type for decryption implementations
  *
- * @constructor
  * @alias module:crypto/algorithms/base.DecryptionAlgorithm
- *
  * @param {object} params parameters
  * @param {string} params.userId  The UserID for the local user
  * @param {module:crypto} params.crypto crypto core
  * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
+ * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
  * @param {string=} params.roomId The ID of the room we will be receiving
  *     from. Null for to-device events.
  */
-var DecryptionAlgorithm = function(params) {
-    this._userId = params.userId;
-    this._crypto = params.crypto;
-    this._olmDevice = params.olmDevice;
-    this._roomId = params.roomId;
-};
-/** */
-module.exports.DecryptionAlgorithm = DecryptionAlgorithm;
+
+class DecryptionAlgorithm {
+    constructor(params) {
+        this._userId = params.userId;
+        this._crypto = params.crypto;
+        this._olmDevice = params.olmDevice;
+        this._baseApis = params.baseApis;
+        this._roomId = params.roomId;
+    }
+
+    /**
+     * Decrypt an event
+     *
+     * @method module:crypto/algorithms/base.DecryptionAlgorithm#decryptEvent
+     * @abstract
+     *
+     * @param {MatrixEvent} event undecrypted event
+     *
+     * @return {Promise<module:crypto~EventDecryptionResult>} promise which
+     * resolves once we have finished decrypting. Rejects with an
+     * `algorithms.DecryptionError` if there is a problem decrypting the event.
+     */
 
-/**
- * Decrypt an event
- *
- * @method module:crypto/algorithms/base.DecryptionAlgorithm#decryptEvent
- * @abstract
- *
- * @param {object} event raw event
- *
- * @return {null} if the event referred to an unknown megolm session
- * @return {module:crypto.DecryptionResult} decryption result
- *
- * @throws {module:crypto/algorithms/base.DecryptionError} if there is a
- *   problem decrypting the event
- */
+    /**
+     * Handle a key event
+     *
+     * @method module:crypto/algorithms/base.DecryptionAlgorithm#onRoomKeyEvent
+     *
+     * @param {module:models/event.MatrixEvent} params event key event
+     */
+    onRoomKeyEvent(params) {}
+    // ignore by default
+
+
+    /**
+     * Import a room key
+     *
+     * @param {module:crypto/OlmDevice.MegolmSessionData} session
+     */
+    importRoomKey(session) {}
+    // ignore by default
+
 
-/**
- * Handle a key event
- *
- * @method module:crypto/algorithms/base.DecryptionAlgorithm#onRoomKeyEvent
- *
- * @param {module:models/event.MatrixEvent} event key event
- */
-DecryptionAlgorithm.prototype.onRoomKeyEvent = function(params) {
-    // ignore by default
-};
+    /**
+     * Determine if we have the keys necessary to respond to a room key request
+     *
+     * @param {module:crypto~IncomingRoomKeyRequest} keyRequest
+     * @return {Promise<boolean>} true if we have the keys and could (theoretically) share
+     *  them; else false.
+     */
+    hasKeysForKeyRequest(keyRequest) {
+        return _bluebird2.default.resolve(false);
+    }
+
+    /**
+     * Send the response to a room key request
+     *
+     * @param {module:crypto~IncomingRoomKeyRequest} keyRequest
+     */
+    shareKeysWithDevice(keyRequest) {
+        throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm");
+    }
+}
+exports.DecryptionAlgorithm = DecryptionAlgorithm; // https://github.com/jsdoc3/jsdoc/issues/1272
 
 /**
  * Exception thrown when decryption fails
  *
- * @constructor
- * @param {string} msg message describing the problem
+ * @alias module:crypto/algorithms/base.DecryptionError
+ * @param {string} msg user-visible message describing the problem
+ *
+ * @param {Object=} details key/value pairs reported in the logs but not shown
+ *   to the user.
+ *
  * @extends Error
  */
-module.exports.DecryptionError = function(msg) {
-    this.message = msg;
-};
-utils.inherits(module.exports.DecryptionError, Error);
+
+class DecryptionError extends Error {
+    constructor(code, msg, details) {
+        super(msg);
+        this.code = code;
+        this.name = 'DecryptionError';
+        this.detailedString = _detailedStringForDecryptionError(this, details);
+    }
+}
+exports.DecryptionError = DecryptionError; // https://github.com/jsdoc3/jsdoc/issues/1272
+
+function _detailedStringForDecryptionError(err, details) {
+    let result = err.name + '[msg: ' + err.message;
+
+    if (details) {
+        result += ', ' + Object.keys(details).map(k => k + ': ' + details[k]).join(', ');
+    }
+
+    result += ']';
+
+    return result;
+}
 
 /**
- * Registers an encryption/decryption class for a particular algorithm
- *
- * @param {string} algorithm algorithm tag to register for
- *
- * @param {class} encryptor {@link
- *     module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm}
- *     implementation
+ * Exception thrown specifically when we want to warn the user to consider
+ * the security of their conversation before continuing
  *
- * @param {class} decryptor {@link
- *     module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm}
- *     implementation
+ * @param {string} msg message describing the problem
+ * @param {Object} devices userId -> {deviceId -> object}
+ *      set of unknown devices per user we're warning about
+ * @extends Error
  */
-module.exports.registerAlgorithm = function(algorithm, encryptor, decryptor) {
-    module.exports.ENCRYPTION_CLASSES[algorithm] = encryptor;
-    module.exports.DECRYPTION_CLASSES[algorithm] = decryptor;
-};
+class UnknownDeviceError extends Error {
+    constructor(msg, devices) {
+        super(msg);
+        this.name = "UnknownDeviceError";
+        this.devices = devices;
+    }
+}
+
+exports.UnknownDeviceError = UnknownDeviceError; /**
+                                                  * Registers an encryption/decryption class for a particular algorithm
+                                                  *
+                                                  * @param {string} algorithm algorithm tag to register for
+                                                  *
+                                                  * @param {class} encryptor {@link
+                                                  *     module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm}
+                                                  *     implementation
+                                                  *
+                                                  * @param {class} decryptor {@link
+                                                  *     module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm}
+                                                  *     implementation
+                                                  */
+
+function registerAlgorithm(algorithm, encryptor, decryptor) {
+    ENCRYPTION_CLASSES[algorithm] = encryptor;
+    DECRYPTION_CLASSES[algorithm] = decryptor;
+}
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/index.js
@@ -14,17 +14,17 @@ See the License for the specific languag
 limitations under the License.
 */
 "use strict";
 
 /**
  * @module crypto/algorithms
  */
 
-var base = require("./base");
+const base = require("./base");
 
 require("./olm");
 require("./megolm");
 
 /**
  * @see module:crypto/algorithms/base.ENCRYPTION_CLASSES
  */
 module.exports.ENCRYPTION_CLASSES = base.ENCRYPTION_CLASSES;
@@ -32,9 +32,9 @@ module.exports.ENCRYPTION_CLASSES = base
 /**
  * @see module:crypto/algorithms/base.DECRYPTION_CLASSES
  */
 module.exports.DECRYPTION_CLASSES = base.DECRYPTION_CLASSES;
 
 /**
  * @see module:crypto/algorithms/base.DecryptionError
  */
-module.exports.DecryptionError = base.DecryptionError;
+module.exports.DecryptionError = base.DecryptionError;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/megolm.js
@@ -1,10 +1,11 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -16,89 +17,133 @@ limitations under the License.
 "use strict";
 
 /**
  * Defines m.olm encryption/decryption
  *
  * @module crypto/algorithms/megolm
  */
 
-var q = require("q");
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../../src/logger');
 
-var utils = require("../../utils");
-var olmlib = require("../olmlib");
-var base = require("./base");
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const utils = require("../../utils");
+const olmlib = require("../olmlib");
+const base = require("./base");
 
 /**
  * @private
  * @constructor
  *
  * @param {string} sessionId
  *
  * @property {string} sessionId
  * @property {Number} useCount     number of times this session has been used
  * @property {Number} creationTime when the session was created (ms since the epoch)
- * @property {module:client.Promise?} sharePromise  If a share operation is in progress,
- *    a promise which resolves when it is complete.
  *
  * @property {object} sharedWithDevices
  *    devices with which we have shared the session key
  *        userId -> {deviceId -> msgindex}
  */
 function OutboundSessionInfo(sessionId) {
     this.sessionId = sessionId;
     this.useCount = 0;
     this.creationTime = new Date().getTime();
-    this.sharePromise = null;
     this.sharedWithDevices = {};
 }
 
-
 /**
  * Check if it's time to rotate the session
  *
  * @param {Number} rotationPeriodMsgs
  * @param {Number} rotationPeriodMs
  * @return {Boolean}
  */
-OutboundSessionInfo.prototype.needsRotation = function(
-    rotationPeriodMsgs, rotationPeriodMs
-) {
-    var sessionLifetime = new Date().getTime() - this.creationTime;
+OutboundSessionInfo.prototype.needsRotation = function (rotationPeriodMsgs, rotationPeriodMs) {
+    const sessionLifetime = new Date().getTime() - this.creationTime;
 
-    if (this.useCount >= rotationPeriodMsgs ||
-        sessionLifetime >= rotationPeriodMs
-       ) {
-        console.log(
-            "Rotating megolm session after " + this.useCount +
-                " messages, " + sessionLifetime + "ms"
-        );
+    if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) {
+        _logger2.default.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms");
         return true;
     }
 
     return false;
 };
 
+OutboundSessionInfo.prototype.markSharedWithDevice = function (userId, deviceId, chainIndex) {
+    if (!this.sharedWithDevices[userId]) {
+        this.sharedWithDevices[userId] = {};
+    }
+    this.sharedWithDevices[userId][deviceId] = chainIndex;
+};
+
+/**
+ * Determine if this session has been shared with devices which it shouldn't
+ * have been.
+ *
+ * @param {Object} devicesInRoom userId -> {deviceId -> object}
+ *   devices we should shared the session with.
+ *
+ * @return {Boolean} true if we have shared the session with devices which aren't
+ * in devicesInRoom.
+ */
+OutboundSessionInfo.prototype.sharedWithTooManyDevices = function (devicesInRoom) {
+    for (const userId in this.sharedWithDevices) {
+        if (!this.sharedWithDevices.hasOwnProperty(userId)) {
+            continue;
+        }
+
+        if (!devicesInRoom.hasOwnProperty(userId)) {
+            _logger2.default.log("Starting new session because we shared with " + userId);
+            return true;
+        }
+
+        for (const deviceId in this.sharedWithDevices[userId]) {
+            if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) {
+                continue;
+            }
+
+            if (!devicesInRoom[userId].hasOwnProperty(deviceId)) {
+                _logger2.default.log("Starting new session because we shared with " + userId + ":" + deviceId);
+                return true;
+            }
+        }
+    }
+};
 
 /**
  * Megolm encryption implementation
  *
  * @constructor
  * @extends {module:crypto/algorithms/base.EncryptionAlgorithm}
  *
  * @param {object} params parameters, as per
  *     {@link module:crypto/algorithms/base.EncryptionAlgorithm}
  */
 function MegolmEncryption(params) {
     base.EncryptionAlgorithm.call(this, params);
 
-    // OutboundSessionInfo. Null if we haven't yet started setting one up. Note
-    // that even if this is non-null, it may not be ready for use (in which
-    // case _outboundSession.sharePromise will be non-null.)
-    this._outboundSession = null;
+    // the most recent attempt to set up a session. This is used to serialise
+    // the session setups, so that we have a race-free view of which session we
+    // are using, and which devices we have shared the keys with. It resolves
+    // with an OutboundSessionInfo (or undefined, for the first message in the
+    // room).
+    this._setupPromise = _bluebird2.default.resolve();
+
+    // Map of outbound sessions by sessions ID. Used if we need a particular
+    // session (the session we're currently using to send is always obtained
+    // using _setupPromise).
+    this._outboundSessions = {};
 
     // default rotation periods
     this._sessionRotationPeriodMsgs = 100;
     this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000;
 
     if (params.config.rotation_period_ms !== undefined) {
         this._sessionRotationPeriodMs = params.config.rotation_period_ms;
     }
@@ -107,431 +152,828 @@ function MegolmEncryption(params) {
         this._sessionRotationPeriodMsgs = params.config.rotation_period_msgs;
     }
 }
 utils.inherits(MegolmEncryption, base.EncryptionAlgorithm);
 
 /**
  * @private
  *
- * @param {module:models/room} room
+ * @param {Object} devicesInRoom The devices in this room, indexed by user ID
  *
  * @return {module:client.Promise} Promise which resolves to the
  *    OutboundSessionInfo when setup is complete.
  */
-MegolmEncryption.prototype._ensureOutboundSession = function(room) {
-    var self = this;
+MegolmEncryption.prototype._ensureOutboundSession = function (devicesInRoom) {
+    const self = this;
+
+    let session;
 
-    var session = this._outboundSession;
+    // takes the previous OutboundSessionInfo, and considers whether to create
+    // a new one. Also shares the key with any (new) devices in the room.
+    // Updates `session` to hold the final OutboundSessionInfo.
+    //
+    // returns a promise which resolves once the keyshare is successful.
+    async function prepareSession(oldSession) {
+        session = oldSession;
 
-    // need to make a brand new session?
-    if (!session || session.needsRotation(self._sessionRotationPeriodMsgs,
-                                          self._sessionRotationPeriodMs)
-       ) {
-        this._outboundSession = session = this._prepareNewSession(room);
-    }
+        // need to make a brand new session?
+        if (session && session.needsRotation(self._sessionRotationPeriodMsgs, self._sessionRotationPeriodMs)) {
+            _logger2.default.log("Starting new megolm session because we need to rotate.");
+            session = null;
+        }
+
+        // determine if we have shared with anyone we shouldn't have
+        if (session && session.sharedWithTooManyDevices(devicesInRoom)) {
+            session = null;
+        }
 
-    if (session.sharePromise) {
-        // key share already in progress
-        return session.sharePromise;
-    }
+        if (!session) {
+            _logger2.default.log(`Starting new megolm session for room ${self._roomId}`);
+            session = await self._prepareNewSession();
+            self._outboundSessions[session.sessionId] = session;
+        }
 
-    // no share in progress: check if we need to share with any devices
-    var prom = this._getDevicesInRoom(room).then(function(devicesInRoom) {
-        var shareMap = {};
+        // now check if we need to share with any devices
+        const shareMap = {};
 
-        for (var userId in devicesInRoom) {
+        for (const userId in devicesInRoom) {
             if (!devicesInRoom.hasOwnProperty(userId)) {
                 continue;
             }
 
-            var userDevices = devicesInRoom[userId];
+            const userDevices = devicesInRoom[userId];
 
-            for (var deviceId in userDevices) {
+            for (const deviceId in userDevices) {
                 if (!userDevices.hasOwnProperty(deviceId)) {
                     continue;
                 }
 
-                var deviceInfo = userDevices[deviceId];
+                const deviceInfo = userDevices[deviceId];
 
-                if (deviceInfo.isBlocked()) {
-                    continue;
-                }
-
-                var key = deviceInfo.getIdentityKey();
+                const key = deviceInfo.getIdentityKey();
                 if (key == self._olmDevice.deviceCurve25519Key) {
                     // don't bother sending to ourself
                     continue;
                 }
 
-                if (
-                    !session.sharedWithDevices[userId] ||
-                        session.sharedWithDevices[userId][deviceId] === undefined
-                ) {
+                if (!session.sharedWithDevices[userId] || session.sharedWithDevices[userId][deviceId] === undefined) {
                     shareMap[userId] = shareMap[userId] || [];
                     shareMap[userId].push(deviceInfo);
                 }
             }
         }
 
-        return self._shareKeyWithDevices(
-            session, shareMap
-        );
-    }).finally(function() {
-        session.sharePromise = null;
-    }).then(function() {
+        return self._shareKeyWithDevices(session, shareMap);
+    }
+
+    // helper which returns the session prepared by prepareSession
+    function returnSession() {
         return session;
-    });
+    }
+
+    // first wait for the previous share to complete
+    const prom = this._setupPromise.then(prepareSession);
+
+    // _setupPromise resolves to `session` whether or not the share succeeds
+    this._setupPromise = prom.then(returnSession, returnSession);
+
+    // but we return a promise which only resolves if the share was successful.
+    return prom.then(returnSession);
+};
 
-    session.sharePromise = prom;
-    return prom;
+/**
+ * @private
+ *
+ * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session
+ */
+MegolmEncryption.prototype._prepareNewSession = async function () {
+    const sessionId = this._olmDevice.createOutboundGroupSession();
+    const key = this._olmDevice.getOutboundGroupSessionKey(sessionId);
+
+    await this._olmDevice.addInboundGroupSession(this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, key.key, { ed25519: this._olmDevice.deviceEd25519Key });
+
+    if (this._crypto.backupInfo) {
+        // don't wait for it to complete
+        this._crypto.backupGroupSession(this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, key.key).catch(e => {
+            // This throws if the upload failed, but this is fine
+            // since it will have written it to the db and will retry.
+            _logger2.default.log("Failed to back up group session", e);
+        });
+    }
+
+    return new OutboundSessionInfo(sessionId);
 };
 
 /**
  * @private
  *
- * @param {module:models/room} room
+ * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session
+ *
+ * @param {number} chainIndex current chain index
  *
- * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session
+ * @param {object<userId, deviceId>} devicemap
+ *   mapping from userId to deviceId to {@link module:crypto~OlmSessionResult}
+ *
+ * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
+ *    map from userid to list of devices
+ *
+ * @return {array<object<userid, deviceInfo>>}
  */
-MegolmEncryption.prototype._prepareNewSession = function(room) {
-    var session_id = this._olmDevice.createOutboundGroupSession();
-    var key = this._olmDevice.getOutboundGroupSessionKey(session_id);
+MegolmEncryption.prototype._splitUserDeviceMap = function (session, chainIndex, devicemap, devicesByUser) {
+    const maxToDeviceMessagesPerRequest = 20;
+
+    // use an array where the slices of a content map gets stored
+    const mapSlices = [];
+    let currentSliceId = 0; // start inserting in the first slice
+    let entriesInCurrentSlice = 0;
+
+    for (const userId of Object.keys(devicesByUser)) {
+        const devicesToShareWith = devicesByUser[userId];
+        const sessionResults = devicemap[userId];
+
+        for (let i = 0; i < devicesToShareWith.length; i++) {
+            const deviceInfo = devicesToShareWith[i];
+            const deviceId = deviceInfo.deviceId;
+
+            const sessionResult = sessionResults[deviceId];
+            if (!sessionResult.sessionId) {
+                // no session with this device, probably because there
+                // were no one-time keys.
+                //
+                // we could send them a to_device message anyway, as a
+                // signal that they have missed out on the key sharing
+                // message because of the lack of keys, but there's not
+                // much point in that really; it will mostly serve to clog
+                // up to_device inboxes.
 
-    this._olmDevice.addInboundGroupSession(
-        this._roomId, this._olmDevice.deviceCurve25519Key, session_id,
-        key.key, {ed25519: this._olmDevice.deviceEd25519Key}
-    );
+                // mark this device as "handled" because we don't want to try
+                // to claim a one-time-key for dead devices on every message.
+                session.markSharedWithDevice(userId, deviceId, chainIndex);
+
+                // ensureOlmSessionsForUsers has already done the logging,
+                // so just skip it.
+                continue;
+            }
+
+            _logger2.default.log("share keys with device " + userId + ":" + deviceId);
 
-    return new OutboundSessionInfo(session_id);
+            if (entriesInCurrentSlice > maxToDeviceMessagesPerRequest) {
+                // the current slice is filled up. Start inserting into the next slice
+                entriesInCurrentSlice = 0;
+                currentSliceId++;
+            }
+            if (!mapSlices[currentSliceId]) {
+                mapSlices[currentSliceId] = [];
+            }
+
+            mapSlices[currentSliceId].push({
+                userId: userId,
+                deviceInfo: deviceInfo
+            });
+
+            entriesInCurrentSlice++;
+        }
+    }
+    return mapSlices;
 };
 
 /**
  * @private
  *
  * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session
  *
- * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
- *    map from userid to list of devices
+ * @param {number} chainIndex current chain index
+ *
+ * @param {object<userId, deviceInfo>} userDeviceMap
+ *   mapping from userId to deviceInfo
+ *
+ * @param {object} payload fields to include in the encrypted payload
  *
  * @return {module:client.Promise} Promise which resolves once the key sharing
- *     message has been sent.
+ *     for the given userDeviceMap is generated and has been sent.
+ */
+MegolmEncryption.prototype._encryptAndSendKeysToDevices = function (session, chainIndex, userDeviceMap, payload) {
+    const encryptedContent = {
+        algorithm: olmlib.OLM_ALGORITHM,
+        sender_key: this._olmDevice.deviceCurve25519Key,
+        ciphertext: {}
+    };
+    const contentMap = {};
+
+    const promises = [];
+    for (let i = 0; i < userDeviceMap.length; i++) {
+        const val = userDeviceMap[i];
+        const userId = val.userId;
+        const deviceInfo = val.deviceInfo;
+        const deviceId = deviceInfo.deviceId;
+
+        if (!contentMap[userId]) {
+            contentMap[userId] = {};
+        }
+        contentMap[userId][deviceId] = encryptedContent;
+
+        promises.push(olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, deviceInfo, payload));
+    }
+
+    return _bluebird2.default.all(promises).then(() => {
+        return this._baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => {
+            // store that we successfully uploaded the keys of the current slice
+            for (const userId of Object.keys(contentMap)) {
+                for (const deviceId of Object.keys(contentMap[userId])) {
+                    session.markSharedWithDevice(userId, deviceId, chainIndex);
+                }
+            }
+        });
+    });
+};
+
+/**
+ * Re-shares a megolm session key with devices if the key has already been
+ * sent to them.
+ *
+ * @param {string} senderKey The key of the originating device for the session
+ * @param {string} sessionId ID of the outbound session to share
+ * @param {string} userId ID of the user who owns the target device
+ * @param {module:crypto/deviceinfo} device The target device
  */
-MegolmEncryption.prototype._shareKeyWithDevices = function(session, devicesByUser) {
-    var self = this;
+MegolmEncryption.prototype.reshareKeyWithDevice = async function (senderKey, sessionId, userId, device) {
+    const obSessionInfo = this._outboundSessions[sessionId];
+    if (!obSessionInfo) {
+        _logger2.default.debug("Session ID " + sessionId + " not found: not re-sharing keys");
+        return;
+    }
+
+    // The chain index of the key we previously sent this device
+    if (obSessionInfo.sharedWithDevices[userId] === undefined) {
+        _logger2.default.debug("Session ID " + sessionId + " never shared with user " + userId);
+        return;
+    }
+    const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId];
+    if (sentChainIndex === undefined) {
+        _logger2.default.debug("Session ID " + sessionId + " never shared with device " + userId + ":" + device.deviceId);
+        return;
+    }
+
+    // get the key from the inbound session: the outbound one will already
+    // have been ratcheted to the next chain index.
+    const key = await this._olmDevice.getInboundGroupSessionKey(this._roomId, senderKey, sessionId, sentChainIndex);
+
+    if (!key) {
+        _logger2.default.warn("No outbound session key found for " + sessionId + ": not re-sharing keys");
+        return;
+    }
+
+    await olmlib.ensureOlmSessionsForDevices(this._olmDevice, this._baseApis, {
+        [userId]: {
+            [device.deviceId]: device
+        }
+    });
 
-    var key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId);
-    var payload = {
+    const payload = {
+        type: "m.forwarded_room_key",
+        content: {
+            algorithm: olmlib.MEGOLM_ALGORITHM,
+            room_id: this._roomId,
+            session_id: sessionId,
+            session_key: key.key,
+            chain_index: key.chain_index,
+            sender_key: senderKey,
+            sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
+            forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain
+        }
+    };
+
+    const encryptedContent = {
+        algorithm: olmlib.OLM_ALGORITHM,
+        sender_key: this._olmDevice.deviceCurve25519Key,
+        ciphertext: {}
+    };
+    await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, device, payload), await this._baseApis.sendToDevice("m.room.encrypted", {
+        [userId]: {
+            [device.deviceId]: encryptedContent
+        }
+    });
+    _logger2.default.debug(`Re-shared key for session ${sessionId}  with ${userId}:${device.deviceId}`);
+};
+
+/**
+ * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session
+ *
+ * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
+ *    map from userid to list of devices
+ */
+MegolmEncryption.prototype._shareKeyWithDevices = async function (session, devicesByUser) {
+    const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId);
+    const payload = {
         type: "m.room_key",
         content: {
             algorithm: olmlib.MEGOLM_ALGORITHM,
             room_id: this._roomId,
             session_id: session.sessionId,
             session_key: key.key,
-            chain_index: key.chain_index,
+            chain_index: key.chain_index
         }
     };
 
-    var contentMap = {};
-
-    return olmlib.ensureOlmSessionsForDevices(
-        this._olmDevice, this._baseApis, devicesByUser
-    ).then(function(devicemap) {
-        var haveTargets = false;
-
-        for (var userId in devicesByUser) {
-            if (!devicesByUser.hasOwnProperty(userId)) {
-                continue;
-            }
-
-            var devicesToShareWith = devicesByUser[userId];
-            var sessionResults = devicemap[userId];
-
-            for (var i = 0; i < devicesToShareWith.length; i++) {
-                var deviceInfo = devicesToShareWith[i];
-                var deviceId = deviceInfo.deviceId;
+    const devicemap = await olmlib.ensureOlmSessionsForDevices(this._olmDevice, this._baseApis, devicesByUser);
 
-                var sessionResult = sessionResults[deviceId];
-                if (!sessionResult.sessionId) {
-                    // no session with this device, probably because there
-                    // were no one-time keys.
-                    //
-                    // we could send them a to_device message anyway, as a
-                    // signal that they have missed out on the key sharing
-                    // message because of the lack of keys, but there's not
-                    // much point in that really; it will mostly serve to clog
-                    // up to_device inboxes.
-                    //
-                    // ensureOlmSessionsForUsers has already done the logging,
-                    // so just skip it.
-                    continue;
-                }
-
-                console.log(
-                    "sharing keys with device " + userId + ":" + deviceId
-                );
-
-                var encryptedContent = {
-                    algorithm: olmlib.OLM_ALGORITHM,
-                    sender_key: self._olmDevice.deviceCurve25519Key,
-                    ciphertext: {},
-                };
+    const userDeviceMaps = this._splitUserDeviceMap(session, key.chain_index, devicemap, devicesByUser);
 
-                olmlib.encryptMessageForDevice(
-                    encryptedContent.ciphertext,
-                    self._userId,
-                    self._deviceId,
-                    self._olmDevice,
-                    userId,
-                    deviceInfo,
-                    payload
-                );
-
-                if (!contentMap[userId]) {
-                    contentMap[userId] = {};
-                }
-
-                contentMap[userId][deviceId] = encryptedContent;
-                haveTargets = true;
-            }
-        }
-
-        if (!haveTargets) {
-            return q();
-        }
+    for (let i = 0; i < userDeviceMaps.length; i++) {
+        try {
+            await this._encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload);
+            _logger2.default.log(`Completed megolm keyshare in ${this._roomId} ` + `(slice ${i + 1}/${userDeviceMaps.length})`);
+        } catch (e) {
+            _logger2.default.log(`megolm keyshare in ${this._roomId} ` + `(slice ${i + 1}/${userDeviceMaps.length}) failed`);
 
-        // TODO: retries
-        return self._baseApis.sendToDevice("m.room.encrypted", contentMap);
-    }).then(function() {
-        // Add the devices we have shared with to session.sharedWithDevices.
-        //
-        // we deliberately iterate over devicesByUser (ie, the devices we
-        // attempted to share with) rather than the contentMap (those we did
-        // share with), because we don't want to try to claim a one-time-key
-        // for dead devices on every message.
-        for (var userId in devicesByUser) {
-            if (!devicesByUser.hasOwnProperty(userId)) {
-                continue;
-            }
-            if (!session.sharedWithDevices[userId]) {
-                session.sharedWithDevices[userId] = {};
-            }
-            var devicesToShareWith = devicesByUser[userId];
-            for (var i = 0; i < devicesToShareWith.length; i++) {
-                var deviceInfo = devicesToShareWith[i];
-                session.sharedWithDevices[userId][deviceInfo.deviceId] =
-                    key.chain_index;
-            }
+            throw e;
         }
-    });
+    }
 };
 
 /**
  * @inheritdoc
  *
  * @param {module:models/room} room
  * @param {string} eventType
- * @param {object} plaintext event content
+ * @param {object} content plaintext event content
  *
  * @return {module:client.Promise} Promise which resolves to the new event body
  */
-MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
-    var self = this;
-    return this._ensureOutboundSession(room).then(function(session) {
-        var payloadJson = {
+MegolmEncryption.prototype.encryptMessage = function (room, eventType, content) {
+    const self = this;
+    _logger2.default.log(`Starting to encrypt event for ${this._roomId}`);
+
+    return this._getDevicesInRoom(room).then(function (devicesInRoom) {
+        // check if any of these devices are not yet known to the user.
+        // if so, warn the user so they can verify or ignore.
+        self._checkForUnknownDevices(devicesInRoom);
+
+        return self._ensureOutboundSession(devicesInRoom);
+    }).then(function (session) {
+        const payloadJson = {
             room_id: self._roomId,
             type: eventType,
             content: content
         };
 
-        var ciphertext = self._olmDevice.encryptGroupMessage(
-            session.sessionId, JSON.stringify(payloadJson)
-        );
+        const ciphertext = self._olmDevice.encryptGroupMessage(session.sessionId, JSON.stringify(payloadJson));
 
-        var encryptedContent = {
+        const encryptedContent = {
             algorithm: olmlib.MEGOLM_ALGORITHM,
             sender_key: self._olmDevice.deviceCurve25519Key,
             ciphertext: ciphertext,
             session_id: session.sessionId,
-             // Include our device ID so that recipients can send us a
-             // m.new_device message if they don't have our session key.
-            device_id: self._deviceId,
+            // Include our device ID so that recipients can send us a
+            // m.new_device message if they don't have our session key.
+            // XXX: Do we still need this now that m.new_device messages
+            // no longer exist since #483?
+            device_id: self._deviceId
         };
 
         session.useCount++;
         return encryptedContent;
     });
 };
 
 /**
- * @inheritdoc
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
  *
- * @param {module:models/event.MatrixEvent} event  event causing the change
- * @param {module:models/room-member} member  user whose membership changed
- * @param {string=} oldMembership  previous membership
+ * This should not normally be necessary.
  */
-MegolmEncryption.prototype.onRoomMembership = function(event, member, oldMembership) {
-    var newMembership = member.membership;
-
-    if (newMembership === 'join' || newMembership === 'invite') {
-        return;
-    }
-
-    // otherwise we assume the user is leaving, and start a new outbound session.
-    console.log("Discarding outbound megolm session due to change in " +
-                "membership of " + member.userId + " (" + oldMembership +
-                "->" + newMembership + ")");
-
-    // this ensures that we will start a new session on the next message.
-    this._outboundSession = null;
+MegolmEncryption.prototype.forceDiscardSession = function () {
+    this._setupPromise = this._setupPromise.then(() => null);
 };
 
 /**
- * Get the list of devices for all users in the room
+ * Checks the devices we're about to send to and see if any are entirely
+ * unknown to the user.  If so, warn the user, and mark them as known to
+ * give the user a chance to go verify them before re-sending this message.
+ *
+ * @param {Object} devicesInRoom userId -> {deviceId -> object}
+ *   devices we should shared the session with.
+ */
+MegolmEncryption.prototype._checkForUnknownDevices = function (devicesInRoom) {
+    const unknownDevices = {};
+
+    Object.keys(devicesInRoom).forEach(userId => {
+        Object.keys(devicesInRoom[userId]).forEach(deviceId => {
+            const device = devicesInRoom[userId][deviceId];
+            if (device.isUnverified() && !device.isKnown()) {
+                if (!unknownDevices[userId]) {
+                    unknownDevices[userId] = {};
+                }
+                unknownDevices[userId][deviceId] = device;
+            }
+        });
+    });
+
+    if (Object.keys(unknownDevices).length) {
+        // it'd be kind to pass unknownDevices up to the user in this error
+        throw new base.UnknownDeviceError("This room contains unknown devices which have not been verified. " + "We strongly recommend you verify them before continuing.", unknownDevices);
+    }
+};
+
+/**
+ * Get the list of unblocked devices for all users in the room
  *
  * @param {module:models/room} room
  *
  * @return {module:client.Promise} Promise which resolves to a map
  *     from userId to deviceId to deviceInfo
  */
-MegolmEncryption.prototype._getDevicesInRoom = function(room) {
-    // XXX what about rooms where invitees can see the content?
-    var roomMembers = utils.map(room.getJoinedMembers(), function(u) {
+MegolmEncryption.prototype._getDevicesInRoom = async function (room) {
+    const members = await room.getEncryptionTargetMembers();
+    const roomMembers = utils.map(members, function (u) {
         return u.userId;
     });
 
+    // The global value is treated as a default for when rooms don't specify a value.
+    let isBlacklisting = this._crypto.getGlobalBlacklistUnverifiedDevices();
+    if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') {
+        isBlacklisting = room.getBlacklistUnverifiedDevices();
+    }
+
     // We are happy to use a cached version here: we assume that if we already
     // have a list of the user's devices, then we already share an e2e room
     // with them, which means that they will have announced any new devices via
-    // an m.new_device.
-    return this._crypto.downloadKeys(roomMembers, false);
+    // device_lists in their /sync response.  This cache should then be maintained
+    // using all the device_lists changes and left fields.
+    // See https://github.com/vector-im/riot-web/issues/2305 for details.
+    const devices = await this._crypto.downloadKeys(roomMembers, false);
+    // remove any blocked devices
+    for (const userId in devices) {
+        if (!devices.hasOwnProperty(userId)) {
+            continue;
+        }
+
+        const userDevices = devices[userId];
+        for (const deviceId in userDevices) {
+            if (!userDevices.hasOwnProperty(deviceId)) {
+                continue;
+            }
+
+            if (userDevices[deviceId].isBlocked() || userDevices[deviceId].isUnverified() && isBlacklisting) {
+                delete userDevices[deviceId];
+            }
+        }
+    }
+
+    return devices;
 };
 
 /**
  * Megolm decryption implementation
  *
  * @constructor
  * @extends {module:crypto/algorithms/base.DecryptionAlgorithm}
  *
  * @param {object} params parameters, as per
  *     {@link module:crypto/algorithms/base.DecryptionAlgorithm}
  */
 function MegolmDecryption(params) {
     base.DecryptionAlgorithm.call(this, params);
 
     // events which we couldn't decrypt due to unknown sessions / indexes: map from
-    // senderKey|sessionId to list of MatrixEvents
+    // senderKey|sessionId to Set of MatrixEvents
     this._pendingEvents = {};
+
+    // this gets stubbed out by the unit tests.
+    this.olmlib = olmlib;
 }
 utils.inherits(MegolmDecryption, base.DecryptionAlgorithm);
 
 /**
  * @inheritdoc
  *
  * @param {MatrixEvent} event
  *
- * @return {null} The event referred to an unknown megolm session
- * @return {module:crypto.DecryptionResult} decryption result
- *
- * @throws {module:crypto/algorithms/base.DecryptionError} if there is a
- *   problem decrypting the event
+ * returns a promise which resolves to a
+ * {@link module:crypto~EventDecryptionResult} once we have finished
+ * decrypting, or rejects with an `algorithms.DecryptionError` if there is a
+ * problem decrypting the event.
  */
-MegolmDecryption.prototype.decryptEvent = function(event) {
-    var content = event.getWireContent();
+MegolmDecryption.prototype.decryptEvent = async function (event) {
+    const content = event.getWireContent();
 
-    if (!content.sender_key || !content.session_id ||
-        !content.ciphertext
-       ) {
-        throw new base.DecryptionError("Missing fields in input");
+    if (!content.sender_key || !content.session_id || !content.ciphertext) {
+        throw new base.DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input");
     }
 
-    var res;
+    // we add the event to the pending list *before* we start decryption.
+    //
+    // then, if the key turns up while decryption is in progress (and
+    // decryption fails), we will schedule a retry.
+    // (fixes https://github.com/vector-im/riot-web/issues/5001)
+    this._addEventToPendingList(event);
+
+    let res;
     try {
-        res = this._olmDevice.decryptGroupMessage(
-            event.getRoomId(), content.sender_key, content.session_id, content.ciphertext
-        );
+        res = await this._olmDevice.decryptGroupMessage(event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, event.getId(), event.getTs());
     } catch (e) {
-        if (e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') {
-            this._addEventToPendingList(event);
+        let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
+
+        if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') {
+            this._requestKeysForEvent(event);
+
+            errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX';
         }
-        throw new base.DecryptionError(e);
+
+        throw new base.DecryptionError(errorCode, e ? e.toString() : "Unknown Error: Error is undefined", {
+            session: content.sender_key + '|' + content.session_id
+        });
     }
 
     if (res === null) {
         // We've got a message for a session we don't have.
-        this._addEventToPendingList(event);
-        throw new base.DecryptionError("Unknown inbound session id");
+        //
+        // (XXX: We might actually have received this key since we started
+        // decrypting, in which case we'll have scheduled a retry, and this
+        // request will be redundant. We could probably check to see if the
+        // event is still in the pending list; if not, a retry will have been
+        // scheduled, so we needn't send out the request here.)
+        this._requestKeysForEvent(event);
+        throw new base.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", "The sender's device has not sent us the keys for this message.", {
+            session: content.sender_key + '|' + content.session_id
+        });
     }
 
-    var payload = JSON.parse(res.result);
+    // success. We can remove the event from the pending list, if that hasn't
+    // already happened.
+    this._removeEventFromPendingList(event);
+
+    const payload = JSON.parse(res.result);
 
     // belt-and-braces check that the room id matches that indicated by the HS
     // (this is somewhat redundant, since the megolm session is scoped to the
     // room, so neither the sender nor a MITM can lie about the room_id).
     if (payload.room_id !== event.getRoomId()) {
-        throw new base.DecryptionError(
-            "Message intended for room " + payload.room_id
-        );
+        throw new base.DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id);
     }
 
-    event.setClearData(payload, res.keysProved, res.keysClaimed);
+    return {
+        clearEvent: payload,
+        senderCurve25519Key: res.senderKey,
+        claimedEd25519Key: res.keysClaimed.ed25519,
+        forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain
+    };
 };
 
+MegolmDecryption.prototype._requestKeysForEvent = function (event) {
+    const wireContent = event.getWireContent();
+
+    const recipients = event.getKeyRequestRecipients(this._userId);
+
+    this._crypto.requestRoomKey({
+        room_id: event.getRoomId(),
+        algorithm: wireContent.algorithm,
+        sender_key: wireContent.sender_key,
+        session_id: wireContent.session_id
+    }, recipients);
+};
 
 /**
- * Add an event to the list of those we couldn't decrypt the first time we
- * saw them.
+ * Add an event to the list of those awaiting their session keys.
  *
  * @private
  *
  * @param {module:models/event.MatrixEvent} event
  */
-MegolmDecryption.prototype._addEventToPendingList = function(event) {
-    var content = event.getWireContent();
-    var k = content.sender_key + "|" + content.session_id;
+MegolmDecryption.prototype._addEventToPendingList = function (event) {
+    const content = event.getWireContent();
+    const k = content.sender_key + "|" + content.session_id;
     if (!this._pendingEvents[k]) {
-        this._pendingEvents[k] = [];
+        this._pendingEvents[k] = new Set();
     }
-    this._pendingEvents[k].push(event);
+    this._pendingEvents[k].add(event);
+};
+
+/**
+ * Remove an event from the list of those awaiting their session keys.
+ *
+ * @private
+ *
+ * @param {module:models/event.MatrixEvent} event
+ */
+MegolmDecryption.prototype._removeEventFromPendingList = function (event) {
+    const content = event.getWireContent();
+    const k = content.sender_key + "|" + content.session_id;
+    if (!this._pendingEvents[k]) {
+        return;
+    }
+
+    this._pendingEvents[k].delete(event);
+    if (this._pendingEvents[k].size === 0) {
+        delete this._pendingEvents[k];
+    }
 };
 
 /**
  * @inheritdoc
  *
  * @param {module:models/event.MatrixEvent} event key event
  */
-MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
-    console.log("Adding key from ", event);
-    var content = event.getContent();
+MegolmDecryption.prototype.onRoomKeyEvent = function (event) {
+    const content = event.getContent();
+    const sessionId = content.session_id;
+    let senderKey = event.getSenderKey();
+    let forwardingKeyChain = [];
+    let exportFormat = false;
+    let keysClaimed;
 
-    if (!content.room_id ||
-        !content.session_id ||
-        !content.session_key
-       ) {
-        console.error("key event is missing fields");
+    if (!content.room_id || !sessionId || !content.session_key) {
+        _logger2.default.error("key event is missing fields");
+        return;
+    }
+
+    if (!senderKey) {
+        _logger2.default.error("key event has no sender key (not encrypted?)");
         return;
     }
 
-    this._olmDevice.addInboundGroupSession(
-        content.room_id, event.getSenderKey(), content.session_id,
-        content.session_key, event.getKeysClaimed()
-    );
+    if (event.getType() == "m.forwarded_room_key") {
+        exportFormat = true;
+        forwardingKeyChain = content.forwarding_curve25519_key_chain;
+        if (!utils.isArray(forwardingKeyChain)) {
+            forwardingKeyChain = [];
+        }
+
+        // copy content before we modify it
+        forwardingKeyChain = forwardingKeyChain.slice();
+        forwardingKeyChain.push(senderKey);
 
-    var k = event.getSenderKey() + "|" + content.session_id;
-    var pending = this._pendingEvents[k];
-    if (pending) {
+        senderKey = content.sender_key;
+        if (!senderKey) {
+            _logger2.default.error("forwarded_room_key event is missing sender_key field");
+            return;
+        }
+
+        const ed25519Key = content.sender_claimed_ed25519_key;
+        if (!ed25519Key) {
+            _logger2.default.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`);
+            return;
+        }
+
+        keysClaimed = {
+            ed25519: ed25519Key
+        };
+    } else {
+        keysClaimed = event.getKeysClaimed();
+    }
+
+    _logger2.default.log(`Adding key for megolm session ${senderKey}|${sessionId}`);
+    return this._olmDevice.addInboundGroupSession(content.room_id, senderKey, forwardingKeyChain, sessionId, content.session_key, keysClaimed, exportFormat).then(() => {
         // have another go at decrypting events sent with this session.
-        delete this._pendingEvents[k];
+        this._retryDecryption(senderKey, sessionId).then(success => {
+            // cancel any outstanding room key requests for this session.
+            // Only do this if we managed to decrypt every message in the
+            // session, because if we didn't, we leave the other key
+            // requests in the hopes that someone sends us a key that
+            // includes an earlier index.
+            if (success) {
+                this._crypto.cancelRoomKeyRequest({
+                    algorithm: content.algorithm,
+                    room_id: content.room_id,
+                    session_id: content.session_id,
+                    sender_key: senderKey
+                });
+            }
+        });
+    }).then(() => {
+        if (this._crypto.backupInfo) {
+            // don't wait for the keys to be backed up for the server
+            this._crypto.backupGroupSession(content.room_id, senderKey, forwardingKeyChain, content.session_id, content.session_key, keysClaimed, exportFormat).catch(e => {
+                // This throws if the upload failed, but this is fine
+                // since it will have written it to the db and will retry.
+                _logger2.default.log("Failed to back up group session", e);
+            });
+        }
+    }).catch(e => {
+        _logger2.default.error(`Error handling m.room_key_event: ${e}`);
+    });
+};
 
-        for (var i = 0; i < pending.length; i++) {
-            try {
-                this.decryptEvent(pending[i]);
-                console.log("successful re-decryption of", pending[i]);
-            } catch (e) {
-                console.log("Still can't decrypt", pending[i], e.stack || e);
-            }
-        }
-    }
+/**
+ * @inheritdoc
+ */
+MegolmDecryption.prototype.hasKeysForKeyRequest = function (keyRequest) {
+    const body = keyRequest.requestBody;
+
+    return this._olmDevice.hasInboundSessionKeys(body.room_id, body.sender_key, body.session_id
+    // TODO: ratchet index
+    );
 };
 
-base.registerAlgorithm(
-    olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption
-);
+/**
+ * @inheritdoc
+ */
+MegolmDecryption.prototype.shareKeysWithDevice = function (keyRequest) {
+    const userId = keyRequest.userId;
+    const deviceId = keyRequest.deviceId;
+    const deviceInfo = this._crypto.getStoredDevice(userId, deviceId);
+    const body = keyRequest.requestBody;
+
+    this.olmlib.ensureOlmSessionsForDevices(this._olmDevice, this._baseApis, {
+        [userId]: [deviceInfo]
+    }).then(devicemap => {
+        const olmSessionResult = devicemap[userId][deviceId];
+        if (!olmSessionResult.sessionId) {
+            // no session with this device, probably because there
+            // were no one-time keys.
+            //
+            // ensureOlmSessionsForUsers has already done the logging,
+            // so just skip it.
+            return null;
+        }
+
+        _logger2.default.log("sharing keys for session " + body.sender_key + "|" + body.session_id + " with device " + userId + ":" + deviceId);
+
+        return this._buildKeyForwardingMessage(body.room_id, body.sender_key, body.session_id);
+    }).then(payload => {
+        const encryptedContent = {
+            algorithm: olmlib.OLM_ALGORITHM,
+            sender_key: this._olmDevice.deviceCurve25519Key,
+            ciphertext: {}
+        };
+
+        return this.olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, deviceInfo, payload).then(() => {
+            const contentMap = {
+                [userId]: {
+                    [deviceId]: encryptedContent
+                }
+            };
+
+            // TODO: retries
+            return this._baseApis.sendToDevice("m.room.encrypted", contentMap);
+        });
+    }).done();
+};
+
+MegolmDecryption.prototype._buildKeyForwardingMessage = async function (roomId, senderKey, sessionId) {
+    const key = await this._olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId);
+
+    return {
+        type: "m.forwarded_room_key",
+        content: {
+            algorithm: olmlib.MEGOLM_ALGORITHM,
+            room_id: roomId,
+            sender_key: senderKey,
+            sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
+            session_id: sessionId,
+            session_key: key.key,
+            chain_index: key.chain_index,
+            forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain
+        }
+    };
+};
+
+/**
+ * @inheritdoc
+ *
+ * @param {module:crypto/OlmDevice.MegolmSessionData} session
+ */
+MegolmDecryption.prototype.importRoomKey = function (session) {
+    return this._olmDevice.addInboundGroupSession(session.room_id, session.sender_key, session.forwarding_curve25519_key_chain, session.session_id, session.session_key, session.sender_claimed_keys, true).then(() => {
+        if (this._crypto.backupInfo) {
+            // don't wait for it to complete
+            this._crypto.backupGroupSession(session.room_id, session.sender_key, session.forwarding_curve25519_key_chain, session.session_id, session.session_key, session.sender_claimed_keys, true).catch(e => {
+                // This throws if the upload failed, but this is fine
+                // since it will have written it to the db and will retry.
+                _logger2.default.log("Failed to back up group session", e);
+            });
+        }
+        // have another go at decrypting events sent with this session.
+        this._retryDecryption(session.sender_key, session.session_id);
+    });
+};
+
+/**
+ * Have another go at decrypting events after we receive a key
+ *
+ * @private
+ * @param {String} senderKey
+ * @param {String} sessionId
+ *
+ * @return {Boolean} whether all messages were successfully decrypted
+ */
+MegolmDecryption.prototype._retryDecryption = async function (senderKey, sessionId) {
+    const k = senderKey + "|" + sessionId;
+    const pending = this._pendingEvents[k];
+    if (!pending) {
+        return true;
+    }
+
+    delete this._pendingEvents[k];
+
+    await _bluebird2.default.all([...pending].map(async ev => {
+        try {
+            await ev.attemptDecryption(this._crypto);
+        } catch (e) {
+            // don't die if something goes wrong
+        }
+    }));
+
+    return !this._pendingEvents[k];
+};
+
+base.registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption);
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/algorithms/olm.js
@@ -15,25 +15,33 @@ limitations under the License.
 */
 "use strict";
 
 /**
  * Defines m.olm encryption/decryption
  *
  * @module crypto/algorithms/olm
  */
-var q = require('q');
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
 
-var utils = require("../../utils");
-var olmlib = require("../olmlib");
-var DeviceInfo = require("../deviceinfo");
-var DeviceVerification = DeviceInfo.DeviceVerification;
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
+const utils = require("../../utils");
+const olmlib = require("../olmlib");
+const DeviceInfo = require("../deviceinfo");
+const DeviceVerification = DeviceInfo.DeviceVerification;
 
-var base = require("./base");
+const base = require("./base");
 
 /**
  * Olm encryption implementation
  *
  * @constructor
  * @extends {module:crypto/algorithms/base.EncryptionAlgorithm}
  *
  * @param {object} params parameters, as per
@@ -47,97 +55,97 @@ function OlmEncryption(params) {
 utils.inherits(OlmEncryption, base.EncryptionAlgorithm);
 
 /**
  * @private
 
  * @param {string[]} roomMembers list of currently-joined users in the room
  * @return {module:client.Promise} Promise which resolves when setup is complete
  */
-OlmEncryption.prototype._ensureSession = function(roomMembers) {
+OlmEncryption.prototype._ensureSession = function (roomMembers) {
     if (this._prepPromise) {
         // prep already in progress
         return this._prepPromise;
     }
 
     if (this._sessionPrepared) {
         // prep already done
-        return q();
+        return _bluebird2.default.resolve();
     }
 
-    var self = this;
-    this._prepPromise = self._crypto.downloadKeys(roomMembers, true).then(function(res) {
+    const self = this;
+    this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function (res) {
         return self._crypto.ensureOlmSessionsForUsers(roomMembers);
-    }).then(function() {
+    }).then(function () {
         self._sessionPrepared = true;
-    }).finally(function() {
+    }).finally(function () {
         self._prepPromise = null;
     });
     return this._prepPromise;
 };
 
 /**
  * @inheritdoc
  *
  * @param {module:models/room} room
  * @param {string} eventType
- * @param {object} plaintext event content
+ * @param {object} content plaintext event content
  *
  * @return {module:client.Promise} Promise which resolves to the new event body
  */
-OlmEncryption.prototype.encryptMessage = function(room, eventType, content) {
+OlmEncryption.prototype.encryptMessage = async function (room, eventType, content) {
     // pick the list of recipients based on the membership list.
     //
     // TODO: there is a race condition here! What if a new user turns up
     // just as you are sending a secret message?
 
-    var users = utils.map(room.getJoinedMembers(), function(u) {
+    const members = await room.getEncryptionTargetMembers();
+
+    const users = utils.map(members, function (u) {
         return u.userId;
     });
 
-    var self = this;
-    return this._ensureSession(users).then(function() {
-        var payloadFields = {
-            room_id: room.roomId,
-            type: eventType,
-            content: content,
-        };
+    const self = this;
+    await this._ensureSession(users);
 
-        var encryptedContent = {
-            algorithm: olmlib.OLM_ALGORITHM,
-            sender_key: self._olmDevice.deviceCurve25519Key,
-            ciphertext: {},
-        };
+    const payloadFields = {
+        room_id: room.roomId,
+        type: eventType,
+        content: content
+    };
 
-        for (var i = 0; i < users.length; ++i) {
-            var userId = users[i];
-            var devices = self._crypto.getStoredDevicesForUser(userId);
+    const encryptedContent = {
+        algorithm: olmlib.OLM_ALGORITHM,
+        sender_key: self._olmDevice.deviceCurve25519Key,
+        ciphertext: {}
+    };
+
+    const promises = [];
 
-            for (var j = 0; j < devices.length; ++j) {
-                var deviceInfo = devices[j];
-                var key = deviceInfo.getIdentityKey();
-                if (key == self._olmDevice.deviceCurve25519Key) {
-                    // don't bother sending to ourself
-                    continue;
-                }
-                if (deviceInfo.verified == DeviceVerification.BLOCKED) {
-                    // don't bother setting up sessions with blocked users
-                    continue;
-                }
+    for (let i = 0; i < users.length; ++i) {
+        const userId = users[i];
+        const devices = self._crypto.getStoredDevicesForUser(userId);
 
-                olmlib.encryptMessageForDevice(
-                    encryptedContent.ciphertext,
-                    self._userId, self._deviceId, self._olmDevice,
-                    userId, deviceInfo, payloadFields
-                );
+        for (let j = 0; j < devices.length; ++j) {
+            const deviceInfo = devices[j];
+            const key = deviceInfo.getIdentityKey();
+            if (key == self._olmDevice.deviceCurve25519Key) {
+                // don't bother sending to ourself
+                continue;
             }
-        }
+            if (deviceInfo.verified == DeviceVerification.BLOCKED) {
+                // don't bother setting up sessions with blocked users
+                continue;
+            }
 
-        return encryptedContent;
-    });
+            promises.push(olmlib.encryptMessageForDevice(encryptedContent.ciphertext, self._userId, self._deviceId, self._olmDevice, userId, deviceInfo, payloadFields));
+        }
+    }
+
+    return await _bluebird2.default.all(promises).return(encryptedContent);
 };
 
 /**
  * Olm decryption implementation
  *
  * @constructor
  * @extends {module:crypto/algorithms/base.DecryptionAlgorithm}
  * @param {object} params parameters, as per
@@ -148,172 +156,139 @@ function OlmDecryption(params) {
 }
 utils.inherits(OlmDecryption, base.DecryptionAlgorithm);
 
 /**
  * @inheritdoc
  *
  * @param {MatrixEvent} event
  *
- * @throws {module:crypto/algorithms/base.DecryptionError} if there is a
- *   problem decrypting the event
+ * returns a promise which resolves to a
+ * {@link module:crypto~EventDecryptionResult} once we have finished
+ * decrypting. Rejects with an `algorithms.DecryptionError` if there is a
+ * problem decrypting the event.
  */
-OlmDecryption.prototype.decryptEvent = function(event) {
-    var content = event.getWireContent();
-    var deviceKey = content.sender_key;
-    var ciphertext = content.ciphertext;
+OlmDecryption.prototype.decryptEvent = async function (event) {
+    const content = event.getWireContent();
+    const deviceKey = content.sender_key;
+    const ciphertext = content.ciphertext;
 
     if (!ciphertext) {
-        throw new base.DecryptionError("Missing ciphertext");
+        throw new base.DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext");
     }
 
     if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) {
-        throw new base.DecryptionError("Not included in recipients");
+        throw new base.DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients");
     }
-    var message = ciphertext[this._olmDevice.deviceCurve25519Key];
-    var payloadString;
+    const message = ciphertext[this._olmDevice.deviceCurve25519Key];
+    let payloadString;
 
     try {
-        payloadString = this._decryptMessage(deviceKey, message);
+        payloadString = await this._decryptMessage(deviceKey, message);
     } catch (e) {
-        console.warn(
-            "Failed to decrypt Olm event (id=" +
-                event.getId() + ") from " + deviceKey +
-                ": " + e.message
-        );
-        throw new base.DecryptionError("Bad Encrypted Message");
+        throw new base.DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", {
+            sender: deviceKey,
+            err: e
+        });
     }
 
-    var payload = JSON.parse(payloadString);
+    const payload = JSON.parse(payloadString);
 
     // check that we were the intended recipient, to avoid unknown-key attack
     // https://github.com/vector-im/vector-web/issues/2483
     if (payload.recipient != this._userId) {
-        console.warn(
-            "Event " + event.getId() + ": Intended recipient " +
-            payload.recipient + " does not match our id " + this._userId
-        );
-        throw new base.DecryptionError(
-            "Message was intented for " + payload.recipient
-        );
+        throw new base.DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient);
     }
 
-    if (payload.recipient_keys.ed25519 !=
-               this._olmDevice.deviceEd25519Key) {
-        console.warn(
-            "Event " + event.getId() + ": Intended recipient ed25519 key " +
-            payload.recipient_keys.ed25519 + " did not match ours"
-        );
-        throw new base.DecryptionError("Message not intended for this device");
+    if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) {
+        throw new base.DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", {
+            intended: payload.recipient_keys.ed25519,
+            our_key: this._olmDevice.deviceEd25519Key
+        });
     }
 
     // check that the original sender matches what the homeserver told us, to
     // avoid people masquerading as others.
     // (this check is also provided via the sender's embedded ed25519 key,
     // which is checked elsewhere).
     if (payload.sender != event.getSender()) {
-        console.warn(
-            "Event " + event.getId() + ": original sender " + payload.sender +
-            " does not match reported sender " + event.getSender()
-        );
-        throw new base.DecryptionError(
-            "Message forwarded from " + payload.sender
-        );
+        throw new base.DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, {
+            reported_sender: event.getSender()
+        });
     }
 
     // Olm events intended for a room have a room_id.
     if (payload.room_id !== event.getRoomId()) {
-        console.warn(
-            "Event " + event.getId() + ": original room " + payload.room_id +
-            " does not match reported room " + event.room_id
-        );
-        throw new base.DecryptionError(
-            "Message intended for room " + payload.room_id
-        );
+        throw new base.DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, {
+            reported_room: event.room_id
+        });
     }
 
-    event.setClearData(payload, {curve25519: deviceKey}, payload.keys || {});
+    const claimedKeys = payload.keys || {};
+
+    return {
+        clearEvent: payload,
+        senderCurve25519Key: deviceKey,
+        claimedEd25519Key: claimedKeys.ed25519 || null
+    };
 };
 
-
 /**
  * Attempt to decrypt an Olm message
  *
  * @param {string} theirDeviceIdentityKey  Curve25519 identity key of the sender
  * @param {object} message  message object, with 'type' and 'body' fields
  *
  * @return {string} payload, if decrypted successfully.
  */
-OlmDecryption.prototype._decryptMessage = function(theirDeviceIdentityKey, message) {
-    var sessionIds = this._olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey);
+OlmDecryption.prototype._decryptMessage = async function (theirDeviceIdentityKey, message) {
+    const sessionIds = await this._olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey);
 
     // try each session in turn.
-    var decryptionErrors = {};
-    for (var i = 0; i < sessionIds.length; i++) {
-        var sessionId = sessionIds[i];
+    const decryptionErrors = {};
+    for (let i = 0; i < sessionIds.length; i++) {
+        const sessionId = sessionIds[i];
         try {
-            var payload = this._olmDevice.decryptMessage(
-                theirDeviceIdentityKey, sessionId, message.type, message.body
-            );
-            console.log(
-                "Decrypted Olm message from " + theirDeviceIdentityKey +
-                    " with session " + sessionId
-            );
+            const payload = await this._olmDevice.decryptMessage(theirDeviceIdentityKey, sessionId, message.type, message.body);
+            _logger2.default.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId);
             return payload;
         } catch (e) {
-            var foundSession = this._olmDevice.matchesSession(
-                theirDeviceIdentityKey, sessionId, message.type, message.body
-            );
+            const foundSession = await this._olmDevice.matchesSession(theirDeviceIdentityKey, sessionId, message.type, message.body);
 
             if (foundSession) {
                 // decryption failed, but it was a prekey message matching this
                 // session, so it should have worked.
-                throw new Error(
-                    "Error decrypting prekey message with existing session id " +
-                        sessionId + ": " + e.message
-                );
+                throw new Error("Error decrypting prekey message with existing session id " + sessionId + ": " + e.message);
             }
 
             // otherwise it's probably a message for another session; carry on, but
             // keep a record of the error
             decryptionErrors[sessionId] = e.message;
         }
     }
 
     if (message.type !== 0) {
         // not a prekey message, so it should have matched an existing session, but it
         // didn't work.
 
         if (sessionIds.length === 0) {
             throw new Error("No existing sessions");
         }
 
-        throw new Error(
-            "Error decrypting non-prekey message with existing sessions: " +
-                JSON.stringify(decryptionErrors)
-        );
+        throw new Error("Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors));
     }
 
     // prekey message which doesn't match any existing sessions: make a new
     // session.
 
-    var res;
+    let res;
     try {
-        res = this._olmDevice.createInboundSession(
-            theirDeviceIdentityKey, message.type, message.body
-        );
+        res = await this._olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body);
     } catch (e) {
         decryptionErrors["(new)"] = e.message;
-        throw new Error(
-            "Error decrypting prekey message: " +
-                JSON.stringify(decryptionErrors)
-        );
+        throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors));
     }
 
-    console.log(
-        "created new inbound Olm session ID " +
-            res.session_id + " with " + theirDeviceIdentityKey
-    );
+    _logger2.default.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey);
     return res.payload;
 };
 
-
-base.registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);
+base.registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/backup_password.js
@@ -0,0 +1,71 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.keyForExistingBackup = keyForExistingBackup;
+exports.keyForNewBackup = keyForNewBackup;
+
+var _randomstring = require("../randomstring");
+
+const DEFAULT_ITERATIONS = 500000; /*
+                                   Copyright 2018 New Vector Ltd
+                                   
+                                   Licensed under the Apache License, Version 2.0 (the "License");
+                                   you may not use this file except in compliance with the License.
+                                   You may obtain a copy of the License at
+                                   
+                                       http://www.apache.org/licenses/LICENSE-2.0
+                                   
+                                   Unless required by applicable law or agreed to in writing, software
+                                   distributed under the License is distributed on an "AS IS" BASIS,
+                                   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                                   See the License for the specific language governing permissions and
+                                   limitations under the License.
+                                   */
+
+async function keyForExistingBackup(backupData, password) {
+    if (!global.Olm) {
+        throw new Error("Olm is not available");
+    }
+
+    const authData = backupData.auth_data;
+
+    if (!authData.private_key_salt || !authData.private_key_iterations) {
+        throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase");
+    }
+
+    return await deriveKey(password, backupData.auth_data.private_key_salt, backupData.auth_data.private_key_iterations);
+}
+
+async function keyForNewBackup(password) {
+    if (!global.Olm) {
+        throw new Error("Olm is not available");
+    }
+
+    const salt = (0, _randomstring.randomString)(32);
+
+    const key = await deriveKey(password, salt, DEFAULT_ITERATIONS);
+
+    return { key, salt, iterations: DEFAULT_ITERATIONS };
+}
+
+async function deriveKey(password, salt, iterations) {
+    const subtleCrypto = global.crypto.subtle;
+    const TextEncoder = global.TextEncoder;
+    if (!subtleCrypto || !TextEncoder) {
+        // TODO: Implement this for node
+        throw new Error("Password-based backup is not avaiable on this platform");
+    }
+
+    const key = await subtleCrypto.importKey('raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
+
+    const keybits = await subtleCrypto.deriveBits({
+        name: 'PBKDF2',
+        salt: new TextEncoder().encode(salt),
+        iterations: iterations,
+        hash: 'SHA-512'
+    }, key, global.Olm.PRIVATE_KEY_LENGTH * 8);
+
+    return new Uint8Array(keybits);
+}
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/deviceinfo.js
@@ -10,17 +10,16 @@ You may obtain a copy of the License at
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 
-
 /**
  * @module crypto/deviceinfo
  */
 
 /**
   * Information about a user's device
   *
   * @constructor
@@ -29,117 +28,142 @@ limitations under the License.
   * @property {string} deviceId the ID of this device
   *
   * @property {string[]} algorithms list of algorithms supported by this device
   *
   * @property {Object.<string,string>} keys a map from
   *      &lt;key type&gt;:&lt;id&gt; -> &lt;base64-encoded key&gt;>
   *
   * @property {module:crypto/deviceinfo.DeviceVerification} verified
-  *     whether the device has been verified by the user
+  *     whether the device has been verified/blocked by the user
+  *
+  * @property {boolean} known
+  *     whether the user knows of this device's existence (useful when warning
+  *     the user that a user has added new devices)
   *
   * @property {Object} unsigned  additional data from the homeserver
   *
   * @param {string} deviceId id of the device
   */
+
 function DeviceInfo(deviceId) {
     // you can't change the deviceId
     Object.defineProperty(this, 'deviceId', {
         enumerable: true,
-        value: deviceId,
+        value: deviceId
     });
 
     this.algorithms = [];
     this.keys = {};
     this.verified = DeviceVerification.UNVERIFIED;
+    this.known = false;
     this.unsigned = {};
 }
 
 /**
  * rehydrate a DeviceInfo from the session store
  *
  * @param {object} obj  raw object from session store
  * @param {string} deviceId id of the device
  *
  * @return {module:crypto~DeviceInfo} new DeviceInfo
  */
-DeviceInfo.fromStorage = function(obj, deviceId) {
-    var res = new DeviceInfo(deviceId);
-    for (var prop in obj) {
+DeviceInfo.fromStorage = function (obj, deviceId) {
+    const res = new DeviceInfo(deviceId);
+    for (const prop in obj) {
         if (obj.hasOwnProperty(prop)) {
             res[prop] = obj[prop];
         }
     }
     return res;
 };
 
 /**
  * Prepare a DeviceInfo for JSON serialisation in the session store
  *
  * @return {object} deviceinfo with non-serialised members removed
  */
-DeviceInfo.prototype.toStorage = function() {
+DeviceInfo.prototype.toStorage = function () {
     return {
         algorithms: this.algorithms,
         keys: this.keys,
         verified: this.verified,
-        unsigned: this.unsigned,
+        known: this.known,
+        unsigned: this.unsigned
     };
 };
 
 /**
  * Get the fingerprint for this device (ie, the Ed25519 key)
  *
  * @return {string} base64-encoded fingerprint of this device
  */
-DeviceInfo.prototype.getFingerprint = function() {
+DeviceInfo.prototype.getFingerprint = function () {
     return this.keys["ed25519:" + this.deviceId];
 };
 
 /**
  * Get the identity key for this device (ie, the Curve25519 key)
  *
  * @return {string} base64-encoded identity key of this device
  */
-DeviceInfo.prototype.getIdentityKey = function() {
+DeviceInfo.prototype.getIdentityKey = function () {
     return this.keys["curve25519:" + this.deviceId];
 };
 
 /**
  * Get the configured display name for this device, if any
  *
  * @return {string?} displayname
  */
-DeviceInfo.prototype.getDisplayName = function() {
+DeviceInfo.prototype.getDisplayName = function () {
     return this.unsigned.device_display_name || null;
 };
 
 /**
  * Returns true if this device is blocked
  *
  * @return {Boolean} true if blocked
  */
-DeviceInfo.prototype.isBlocked = function() {
+DeviceInfo.prototype.isBlocked = function () {
     return this.verified == DeviceVerification.BLOCKED;
 };
 
 /**
  * Returns true if this device is verified
  *
  * @return {Boolean} true if verified
  */
-DeviceInfo.prototype.isVerified = function() {
+DeviceInfo.prototype.isVerified = function () {
     return this.verified == DeviceVerification.VERIFIED;
 };
 
 /**
+ * Returns true if this device is unverified
+ *
+ * @return {Boolean} true if unverified
+ */
+DeviceInfo.prototype.isUnverified = function () {
+    return this.verified == DeviceVerification.UNVERIFIED;
+};
+
+/**
+ * Returns true if the user knows about this device's existence
+ *
+ * @return {Boolean} true if known
+ */
+DeviceInfo.prototype.isKnown = function () {
+    return this.known == true;
+};
+
+/**
  * @enum
  */
 DeviceInfo.DeviceVerification = {
     VERIFIED: 1,
     UNVERIFIED: 0,
-    BLOCKED: -1,
+    BLOCKED: -1
 };
 
-var DeviceVerification = DeviceInfo.DeviceVerification;
+const DeviceVerification = DeviceInfo.DeviceVerification;
 
 /** */
-module.exports = DeviceInfo;
+module.exports = DeviceInfo;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/index.js
@@ -1,804 +1,1027 @@
 /*
 Copyright 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2018-2019 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 
-
 /**
  * @module crypto
  */
 
-var anotherjson = require('another-json');
-var q = require("q");
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.verificationMethods = undefined;
+exports.isCryptoAvailable = isCryptoAvailable;
+exports.default = Crypto;
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _events = require('events');
+
+var _logger = require('../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _randomstring = require('../randomstring');
+
+var _OutgoingRoomKeyRequestManager = require('./OutgoingRoomKeyRequestManager');
+
+var _OutgoingRoomKeyRequestManager2 = _interopRequireDefault(_OutgoingRoomKeyRequestManager);
+
+var _indexeddbCryptoStore = require('./store/indexeddb-crypto-store');
+
+var _indexeddbCryptoStore2 = _interopRequireDefault(_indexeddbCryptoStore);
+
+var _QRCode = require('./verification/QRCode');
+
+var _SAS = require('./verification/SAS');
+
+var _SAS2 = _interopRequireDefault(_SAS);
+
+var _Error = require('./verification/Error');
 
-var utils = require("../utils");
-var OlmDevice = require("./OlmDevice");
-var olmlib = require("./olmlib");
-var algorithms = require("./algorithms");
-var DeviceInfo = require("./deviceinfo");
-var DeviceVerification = DeviceInfo.DeviceVerification;
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const anotherjson = require('another-json');
+
+const utils = require("../utils");
+const OlmDevice = require("./OlmDevice");
+const olmlib = require("./olmlib");
+const algorithms = require("./algorithms");
+const DeviceInfo = require("./deviceinfo");
+const DeviceVerification = DeviceInfo.DeviceVerification;
+const DeviceList = require('./DeviceList').default;
+
+
+const defaultVerificationMethods = {
+    [_QRCode.ScanQRCode.NAME]: _QRCode.ScanQRCode,
+    [_QRCode.ShowQRCode.NAME]: _QRCode.ShowQRCode,
+    [_SAS2.default.NAME]: _SAS2.default
+};
+
+/**
+ * verification method names
+ */
+const verificationMethods = exports.verificationMethods = {
+    QR_CODE_SCAN: _QRCode.ScanQRCode.NAME,
+    QR_CODE_SHOW: _QRCode.ShowQRCode.NAME,
+    SAS: _SAS2.default.NAME
+};
+
+function isCryptoAvailable() {
+    return Boolean(global.Olm);
+}
+
+const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
+const KEY_BACKUP_KEYS_PER_REQUEST = 200;
 
 /**
  * Cryptography bits
  *
+ * This module is internal to the js-sdk; the public API is via MatrixClient.
+ *
  * @constructor
  * @alias module:crypto
  *
- * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
+ * @internal
  *
- * @param {external:EventEmitter} eventEmitter event source where we can register
- *    for event notifications
+ * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
  *
  * @param {module:store/session/webstorage~WebStorageSessionStore} sessionStore
  *    Store to be used for end-to-end crypto session data
  *
  * @param {string} userId The user ID for the local user
  *
  * @param {string} deviceId The identifier for this device.
+ *
+ * @param {Object} clientStore the MatrixClient data store.
+ *
+ * @param {module:crypto/store/base~CryptoStore} cryptoStore
+ *    storage for the crypto layer.
+ *
+ * @param {RoomList} roomList An initialised RoomList object
+ *
+ * @param {Array} verificationMethods Array of verification methods to use.
+ *    Each element can either be a string from MatrixClient.verificationMethods
+ *    or a class that implements a verification method.
  */
-function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) {
+function Crypto(baseApis, sessionStore, userId, deviceId, clientStore, cryptoStore, roomList, verificationMethods) {
     this._baseApis = baseApis;
     this._sessionStore = sessionStore;
     this._userId = userId;
     this._deviceId = deviceId;
+    this._clientStore = clientStore;
+    this._cryptoStore = cryptoStore;
+    this._roomList = roomList;
+    this._verificationMethods = new Map();
+    if (verificationMethods) {
+        for (const method of verificationMethods) {
+            if (typeof method === "string") {
+                if (defaultVerificationMethods[method]) {
+                    this._verificationMethods.set(method, defaultVerificationMethods[method]);
+                }
+            } else if (method.NAME) {
+                this._verificationMethods.set(method.NAME, method);
+            }
+        }
+    }
 
-    this._initialSyncCompleted = false;
-    // userId -> deviceId -> true
-    this._pendingNewDevices = {};
+    // track whether this device's megolm keys are being backed up incrementally
+    // to the server or not.
+    // XXX: this should probably have a single source of truth from OlmAccount
+    this.backupInfo = null; // The info dict from /room_keys/version
+    this.backupKey = null; // The encryption key object
+    this._checkedForBackup = false; // Have we checked the server for a backup we can use?
+    this._sendingBackups = false; // Are we currently sending backups?
 
-    this._olmDevice = new OlmDevice(sessionStore);
+    this._olmDevice = new OlmDevice(cryptoStore);
+    this._deviceList = new DeviceList(baseApis, cryptoStore, this._olmDevice);
+
+    // the last time we did a check for the number of one-time-keys on the
+    // server.
+    this._lastOneTimeKeyCheck = null;
+    this._oneTimeKeyCheckInProgress = false;
 
     // EncryptionAlgorithm instance for each room
     this._roomEncryptors = {};
 
     // map from algorithm to DecryptionAlgorithm instance, for each room
     this._roomDecryptors = {};
 
-    this._supportedAlgorithms = utils.keys(
-        algorithms.DECRYPTION_CLASSES
-    );
+    this._supportedAlgorithms = utils.keys(algorithms.DECRYPTION_CLASSES);
+
+    this._deviceKeys = {};
+
+    this._globalBlacklistUnverifiedDevices = false;
+
+    this._outgoingRoomKeyRequestManager = new _OutgoingRoomKeyRequestManager2.default(baseApis, this._deviceId, this._cryptoStore);
+
+    // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations
+    // we received in the current sync.
+    this._receivedRoomKeyRequests = [];
+    this._receivedRoomKeyRequestCancellations = [];
+    // true if we are currently processing received room key requests
+    this._processingRoomKeyRequests = false;
+    // controls whether device tracking is delayed
+    // until calling encryptEvent or trackRoomDevices,
+    // or done immediately upon enabling room encryption.
+    this._lazyLoadMembers = false;
+    // in case _lazyLoadMembers is true,
+    // track if an initial tracking of all the room members
+    // has happened for a given room. This is delayed
+    // to avoid loading room members as long as possible.
+    this._roomDeviceTrackingState = {};
+
+    // The timestamp of the last time we forced establishment
+    // of a new session for each device, in milliseconds.
+    // {
+    //     userId: {
+    //         deviceId: 1234567890000,
+    //     },
+    // }
+    this._lastNewSessionForced = {};
+
+    this._verificationTransactions = new Map();
+}
+utils.inherits(Crypto, _events.EventEmitter);
+
+/**
+ * Initialise the crypto module so that it is ready for use
+ *
+ * Returns a promise which resolves once the crypto module is ready for use.
+ */
+Crypto.prototype.init = async function () {
+    _logger2.default.log("Crypto: initialising Olm...");
+    await global.Olm.init();
+    _logger2.default.log("Crypto: initialising Olm device...");
+    await this._olmDevice.init();
+    _logger2.default.log("Crypto: loading device list...");
+    await this._deviceList.load();
 
     // build our device keys: these will later be uploaded
-    this._deviceKeys = {};
-    this._deviceKeys["ed25519:" + this._deviceId] =
-        this._olmDevice.deviceEd25519Key;
-    this._deviceKeys["curve25519:" + this._deviceId] =
-        this._olmDevice.deviceCurve25519Key;
+    this._deviceKeys["ed25519:" + this._deviceId] = this._olmDevice.deviceEd25519Key;
+    this._deviceKeys["curve25519:" + this._deviceId] = this._olmDevice.deviceCurve25519Key;
+
+    _logger2.default.log("Crypto: fetching own devices...");
+    let myDevices = this._deviceList.getRawStoredDevicesForUser(this._userId);
+
+    if (!myDevices) {
+        myDevices = {};
+    }
+
+    if (!myDevices[this._deviceId]) {
+        // add our own deviceinfo to the cryptoStore
+        _logger2.default.log("Crypto: adding this device to the store...");
+        const deviceInfo = {
+            keys: this._deviceKeys,
+            algorithms: this._supportedAlgorithms,
+            verified: DeviceVerification.VERIFIED,
+            known: true
+        };
+
+        myDevices[this._deviceId] = deviceInfo;
+        this._deviceList.storeDevicesForUser(this._userId, myDevices);
+        this._deviceList.saveIfDirty();
+    }
+
+    _logger2.default.log("Crypto: checking for key backup...");
+    this._checkAndStartKeyBackup();
+};
+
+/**
+ * Check the server for an active key backup and
+ * if one is present and has a valid signature from
+ * one of the user's verified devices, start backing up
+ * to it.
+ */
+Crypto.prototype._checkAndStartKeyBackup = async function () {
+    _logger2.default.log("Checking key backup status...");
+    if (this._baseApis.isGuest()) {
+        _logger2.default.log("Skipping key backup check since user is guest");
+        this._checkedForBackup = true;
+        return null;
+    }
+    let backupInfo;
+    try {
+        backupInfo = await this._baseApis.getKeyBackupVersion();
+    } catch (e) {
+        _logger2.default.log("Error checking for active key backup", e);
+        if (e.httpStatus / 100 === 4) {
+            // well that's told us. we won't try again.
+            this._checkedForBackup = true;
+        }
+        return null;
+    }
+    this._checkedForBackup = true;
+
+    const trustInfo = await this.isKeyBackupTrusted(backupInfo);
 
-    // add our own deviceinfo to the sessionstore
-    var deviceInfo = {
-        keys: this._deviceKeys,
-        algorithms: this._supportedAlgorithms,
-        verified: DeviceVerification.VERIFIED,
+    if (trustInfo.usable && !this.backupInfo) {
+        _logger2.default.log("Found usable key backup v" + backupInfo.version + ": enabling key backups");
+        this._baseApis.enableKeyBackup(backupInfo);
+    } else if (!trustInfo.usable && this.backupInfo) {
+        _logger2.default.log("No usable key backup: disabling key backup");
+        this._baseApis.disableKeyBackup();
+    } else if (!trustInfo.usable && !this.backupInfo) {
+        _logger2.default.log("No usable key backup: not enabling key backup");
+    } else if (trustInfo.usable && this.backupInfo) {
+        // may not be the same version: if not, we should switch
+        if (backupInfo.version !== this.backupInfo.version) {
+            _logger2.default.log("On backup version " + this.backupInfo.version + " but found " + "version " + backupInfo.version + ": switching.");
+            this._baseApis.disableKeyBackup();
+            this._baseApis.enableKeyBackup(backupInfo);
+        } else {
+            _logger2.default.log("Backup version " + backupInfo.version + " still current");
+        }
+    }
+
+    return { backupInfo, trustInfo };
+};
+
+Crypto.prototype.setTrustedBackupPubKey = async function (trustedPubKey) {
+    // This should be redundant post cross-signing is a thing, so just
+    // plonk it in localStorage for now.
+    this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey);
+    await this.checkKeyBackup();
+};
+
+/**
+ * Forces a re-check of the key backup and enables/disables it
+ * as appropriate.
+ *
+ * @return {Object} Object with backup info (as returned by
+ *     getKeyBackupVersion) in backupInfo and
+ *     trust information (as returned by isKeyBackupTrusted)
+ *     in trustInfo.
+ */
+Crypto.prototype.checkKeyBackup = async function () {
+    this._checkedForBackup = false;
+    const returnInfo = await this._checkAndStartKeyBackup();
+    return returnInfo;
+};
+
+/**
+ * @param {object} backupInfo key backup info dict from /room_keys/version
+ * @return {object} {
+ *     usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
+ *     sigs: [
+ *         valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
+ *         deviceId: [string],
+ *         device: [DeviceInfo || null],
+ *     ]
+ * }
+ */
+Crypto.prototype.isKeyBackupTrusted = async function (backupInfo) {
+    const ret = {
+        usable: false,
+        trusted_locally: false,
+        sigs: []
     };
-    var myDevices = this._sessionStore.getEndToEndDevicesForUser(
-        this._userId
-    ) || {};
-    myDevices[this._deviceId] = deviceInfo;
-    this._sessionStore.storeEndToEndDevicesForUser(
-        this._userId, myDevices
-    );
+
+    if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.public_key || !backupInfo.auth_data.signatures) {
+        _logger2.default.info("Key backup is absent or missing required data");
+        return ret;
+    }
 
-    _registerEventHandlers(this, eventEmitter);
+    const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey();
+
+    if (backupInfo.auth_data.public_key === trustedPubkey) {
+        _logger2.default.info("Backup public key " + trustedPubkey + " is trusted locally");
+        ret.trusted_locally = true;
+    }
+
+    const mySigs = backupInfo.auth_data.signatures[this._userId] || [];
 
-    // map from userId -> deviceId -> roomId -> timestamp
-    this._lastNewDeviceMessageTsByUserDeviceRoom = {};
-}
+    for (const keyId of Object.keys(mySigs)) {
+        const keyIdParts = keyId.split(':');
+        if (keyIdParts[0] !== 'ed25519') {
+            _logger2.default.log("Ignoring unknown signature type: " + keyIdParts[0]);
+            continue;
+        }
+        const sigInfo = { deviceId: keyIdParts[1] }; // XXX: is this how we're supposed to get the device ID?
+        const device = this._deviceList.getStoredDevice(this._userId, sigInfo.deviceId);
+        if (device) {
+            sigInfo.device = device;
+            try {
+                await olmlib.verifySignature(this._olmDevice,
+                // verifySignature modifies the object so we need to copy
+                // if we verify more than one sig
+                Object.assign({}, backupInfo.auth_data), this._userId, device.deviceId, device.getFingerprint());
+                sigInfo.valid = true;
+            } catch (e) {
+                _logger2.default.info("Bad signature from key ID " + keyId + " userID " + this._userId + " device ID " + device.deviceId + " fingerprint: " + device.getFingerprint(), backupInfo.auth_data, e);
+                sigInfo.valid = false;
+            }
+        } else {
+            sigInfo.valid = null; // Can't determine validity because we don't have the signing device
+            _logger2.default.info("Ignoring signature from unknown key " + keyId);
+        }
+        ret.sigs.push(sigInfo);
+    }
 
-function _registerEventHandlers(crypto, eventEmitter) {
-    eventEmitter.on("sync", function(syncState, oldState, data) {
+    ret.usable = ret.sigs.some(s => s.valid && s.device.isVerified()) || ret.trusted_locally;
+    return ret;
+};
+
+/**
+ */
+Crypto.prototype.enableLazyLoading = function () {
+    this._lazyLoadMembers = true;
+};
+
+/**
+ * Tell the crypto module to register for MatrixClient events which it needs to
+ * listen for
+ *
+ * @param {external:EventEmitter} eventEmitter event source where we can register
+ *    for event notifications
+ */
+Crypto.prototype.registerEventHandlers = function (eventEmitter) {
+    const crypto = this;
+
+    eventEmitter.on("RoomMember.membership", function (event, member, oldMembership) {
         try {
-            if (syncState == "PREPARED") {
-                // XXX ugh. we're assuming the eventEmitter is a MatrixClient.
-                // how can we avoid doing so?
-                var rooms = eventEmitter.getRooms();
-                crypto._onInitialSyncCompleted(rooms);
-            }
+            crypto._onRoomMembership(event, member, oldMembership);
         } catch (e) {
-            console.error("Error handling sync", e);
+            _logger2.default.error("Error handling membership change:", e);
         }
     });
 
-    eventEmitter.on("RoomMember.membership", function(event, member, oldMembership) {
-        try {
-            crypto._onRoomMembership(event, member, oldMembership);
-        } catch (e) {
-             console.error("Error handling membership change:", e);
-        }
+    eventEmitter.on("toDeviceEvent", function (event) {
+        crypto._onToDeviceEvent(event);
     });
+};
 
-    eventEmitter.on("toDeviceEvent", function(event) {
-        try {
-            if (event.getType() == "m.room_key") {
-                crypto._onRoomKeyEvent(event);
-            } else if (event.getType() == "m.new_device") {
-                crypto._onNewDeviceEvent(event);
-            }
-        } catch (e) {
-            console.error("Error handling toDeviceEvent:", e);
-        }
-    });
+/** Start background processes related to crypto */
+Crypto.prototype.start = function () {
+    this._outgoingRoomKeyRequestManager.start();
+};
 
-    eventEmitter.on("event", function(event) {
-        try {
-            if (!event.isState() || event.getType() != "m.room.encryption") {
-                return;
-            }
-            crypto._onCryptoEvent(event);
-        } catch (e) {
-            console.error("Error handling crypto event:", e);
-        }
-    });
-}
+/** Stop background processes related to crypto */
+Crypto.prototype.stop = function () {
+    this._outgoingRoomKeyRequestManager.stop();
+    this._deviceList.stop();
+};
 
 /**
  * @return {string} The version of Olm.
  */
-Crypto.getOlmVersion = function() {
+Crypto.getOlmVersion = function () {
     return OlmDevice.getOlmVersion();
 };
 
 /**
  * Get the Ed25519 key for this device
  *
  * @return {string} base64-encoded ed25519 key.
  */
-Crypto.prototype.getDeviceEd25519Key = function() {
+Crypto.prototype.getDeviceEd25519Key = function () {
     return this._olmDevice.deviceEd25519Key;
 };
 
 /**
- * Upload the device keys to the homeserver and ensure that the
- * homeserver has enough one-time keys.
- * @param {number} maxKeys The maximum number of keys to generate
+ * Set the global override for whether the client should ever send encrypted
+ * messages to unverified devices.  This provides the default for rooms which
+ * do not specify a value.
+ *
+ * @param {boolean} value whether to blacklist all unverified devices by default
+ */
+Crypto.prototype.setGlobalBlacklistUnverifiedDevices = function (value) {
+    this._globalBlacklistUnverifiedDevices = value;
+};
+
+/**
+ * @return {boolean} whether to blacklist all unverified devices by default
+ */
+Crypto.prototype.getGlobalBlacklistUnverifiedDevices = function () {
+    return this._globalBlacklistUnverifiedDevices;
+};
+
+/**
+ * Upload the device keys to the homeserver.
  * @return {object} A promise that will resolve when the keys are uploaded.
  */
-Crypto.prototype.uploadKeys = function(maxKeys) {
-    var self = this;
-    return _uploadDeviceKeys(this).then(function(res) {
-        // We need to keep a pool of one time public keys on the server so that
-        // other devices can start conversations with us. But we can only store
-        // a finite number of private keys in the olm Account object.
-        // To complicate things further then can be a delay between a device
-        // claiming a public one time key from the server and it sending us a
-        // message. We need to keep the corresponding private key locally until
-        // we receive the message.
-        // But that message might never arrive leaving us stuck with duff
-        // private keys clogging up our local storage.
-        // So we need some kind of enginering compromise to balance all of
-        // these factors.
+Crypto.prototype.uploadDeviceKeys = function () {
+    const crypto = this;
+    const userId = crypto._userId;
+    const deviceId = crypto._deviceId;
 
-        // We first find how many keys the server has for us.
-        var keyCount = res.one_time_key_counts.signed_curve25519 || 0;
-        // We then check how many keys we can store in the Account object.
-        var maxOneTimeKeys = self._olmDevice.maxNumberOfOneTimeKeys();
-        // Try to keep at most half that number on the server. This leaves the
-        // rest of the slots free to hold keys that have been claimed from the
-        // server but we haven't recevied a message for.
-        // If we run out of slots when generating new keys then olm will
-        // discard the oldest private keys first. This will eventually clean
-        // out stale private keys that won't receive a message.
-        var keyLimit = Math.floor(maxOneTimeKeys / 2);
-        // We work out how many new keys we need to create to top up the server
-        // If there are too many keys on the server then we don't need to
-        // create any more keys.
-        var numberToGenerate = Math.max(keyLimit - keyCount, 0);
-        if (maxKeys !== undefined) {
-            // Creating keys can be an expensive operation so we limit the
-            // number we generate in one go to avoid blocking the application
-            // for too long.
-            numberToGenerate = Math.min(numberToGenerate, maxKeys);
-        }
+    const deviceKeys = {
+        algorithms: crypto._supportedAlgorithms,
+        device_id: deviceId,
+        keys: crypto._deviceKeys,
+        user_id: userId
+    };
 
-        if (numberToGenerate <= 0) {
-            // If we don't need to generate any keys then we are done.
-            return;
-        }
-
-        // Ask olm to generate new one time keys, then upload them to synapse.
-        self._olmDevice.generateOneTimeKeys(numberToGenerate);
-        return _uploadOneTimeKeys(self);
+    return crypto._signObject(deviceKeys).then(() => {
+        crypto._baseApis.uploadKeysRequest({
+            device_keys: deviceKeys
+        }, {
+            // for now, we set the device id explicitly, as we may not be using the
+            // same one as used in login.
+            device_id: deviceId
+        });
     });
 };
 
-// returns a promise which resolves to the response
-function _uploadDeviceKeys(crypto) {
-    var userId = crypto._userId;
-    var deviceId = crypto._deviceId;
+/**
+ * Stores the current one_time_key count which will be handled later (in a call of
+ * onSyncCompleted). The count is e.g. coming from a /sync response.
+ *
+ * @param {Number} currentCount The current count of one_time_keys to be stored
+ */
+Crypto.prototype.updateOneTimeKeyCount = function (currentCount) {
+    if (isFinite(currentCount)) {
+        this._oneTimeKeyCount = currentCount;
+    } else {
+        throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
+    }
+};
+
+// check if it's time to upload one-time keys, and do so if so.
+function _maybeUploadOneTimeKeys(crypto) {
+    // frequency with which to check & upload one-time keys
+    const uploadPeriod = 1000 * 60; // one minute
+
+    // max number of keys to upload at once
+    // Creating keys can be an expensive operation so we limit the
+    // number we generate in one go to avoid blocking the application
+    // for too long.
+    const maxKeysPerCycle = 5;
+
+    if (crypto._oneTimeKeyCheckInProgress) {
+        return;
+    }
+
+    const now = Date.now();
+    if (crypto._lastOneTimeKeyCheck !== null && now - crypto._lastOneTimeKeyCheck < uploadPeriod) {
+        // we've done a key upload recently.
+        return;
+    }
+
+    crypto._lastOneTimeKeyCheck = now;
+
+    // We need to keep a pool of one time public keys on the server so that
+    // other devices can start conversations with us. But we can only store
+    // a finite number of private keys in the olm Account object.
+    // To complicate things further then can be a delay between a device
+    // claiming a public one time key from the server and it sending us a
+    // message. We need to keep the corresponding private key locally until
+    // we receive the message.
+    // But that message might never arrive leaving us stuck with duff
+    // private keys clogging up our local storage.
+    // So we need some kind of enginering compromise to balance all of
+    // these factors.
 
-    var deviceKeys = {
-        algorithms: crypto._supportedAlgorithms,
-        device_id: deviceId,
-        keys: crypto._deviceKeys,
-        user_id: userId,
-    };
-    crypto._signObject(deviceKeys);
+    // Check how many keys we can store in the Account object.
+    const maxOneTimeKeys = crypto._olmDevice.maxNumberOfOneTimeKeys();
+    // Try to keep at most half that number on the server. This leaves the
+    // rest of the slots free to hold keys that have been claimed from the
+    // server but we haven't recevied a message for.
+    // If we run out of slots when generating new keys then olm will
+    // discard the oldest private keys first. This will eventually clean
+    // out stale private keys that won't receive a message.
+    const keyLimit = Math.floor(maxOneTimeKeys / 2);
+
+    function uploadLoop(keyCount) {
+        if (keyLimit <= keyCount) {
+            // If we don't need to generate any more keys then we are done.
+            return _bluebird2.default.resolve();
+        }
+
+        const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle);
 
-    return crypto._baseApis.uploadKeysRequest({
-        device_keys: deviceKeys,
-    }, {
-        // for now, we set the device id explicitly, as we may not be using the
-        // same one as used in login.
-        device_id: deviceId,
-    });
+        // Ask olm to generate new one time keys, then upload them to synapse.
+        return crypto._olmDevice.generateOneTimeKeys(keysThisLoop).then(() => {
+            return _uploadOneTimeKeys(crypto);
+        }).then(res => {
+            if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) {
+                // if the response contains a more up to date value use this
+                // for the next loop
+                return uploadLoop(res.one_time_key_counts.signed_curve25519);
+            } else {
+                throw new Error("response for uploading keys does not contain " + "one_time_key_counts.signed_curve25519");
+            }
+        });
+    }
+
+    crypto._oneTimeKeyCheckInProgress = true;
+    _bluebird2.default.resolve().then(() => {
+        if (crypto._oneTimeKeyCount !== undefined) {
+            // We already have the current one_time_key count from a /sync response.
+            // Use this value instead of asking the server for the current key count.
+            return _bluebird2.default.resolve(crypto._oneTimeKeyCount);
+        }
+        // ask the server how many keys we have
+        return crypto._baseApis.uploadKeysRequest({}, {
+            device_id: crypto._deviceId
+        }).then(res => {
+            return res.one_time_key_counts.signed_curve25519 || 0;
+        });
+    }).then(keyCount => {
+        // Start the uploadLoop with the current keyCount. The function checks if
+        // we need to upload new keys or not.
+        // If there are too many keys on the server then we don't need to
+        // create any more keys.
+        return uploadLoop(keyCount);
+    }).catch(e => {
+        _logger2.default.error("Error uploading one-time keys", e.stack || e);
+    }).finally(() => {
+        // reset _oneTimeKeyCount to prevent start uploading based on old data.
+        // it will be set again on the next /sync-response
+        crypto._oneTimeKeyCount = undefined;
+        crypto._oneTimeKeyCheckInProgress = false;
+    }).done();
 }
 
 // returns a promise which resolves to the response
-function _uploadOneTimeKeys(crypto) {
-    var oneTimeKeys = crypto._olmDevice.getOneTimeKeys();
-    var oneTimeJson = {};
+async function _uploadOneTimeKeys(crypto) {
+    const oneTimeKeys = await crypto._olmDevice.getOneTimeKeys();
+    const oneTimeJson = {};
 
-    for (var keyId in oneTimeKeys.curve25519) {
+    const promises = [];
+
+    for (const keyId in oneTimeKeys.curve25519) {
         if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) {
-            var k = {
-                key: oneTimeKeys.curve25519[keyId],
+            const k = {
+                key: oneTimeKeys.curve25519[keyId]
             };
-            crypto._signObject(k);
             oneTimeJson["signed_curve25519:" + keyId] = k;
+            promises.push(crypto._signObject(k));
         }
     }
 
-    return crypto._baseApis.uploadKeysRequest({
+    await _bluebird2.default.all(promises);
+
+    const res = await crypto._baseApis.uploadKeysRequest({
         one_time_keys: oneTimeJson
     }, {
         // for now, we set the device id explicitly, as we may not be using the
         // same one as used in login.
-        device_id: crypto._deviceId,
-    }).then(function(res) {
-        crypto._olmDevice.markKeysAsPublished();
-        return res;
+        device_id: crypto._deviceId
     });
+
+    await crypto._olmDevice.markKeysAsPublished();
+    return res;
 }
 
 /**
  * Download the keys for a list of users and stores the keys in the session
  * store.
  * @param {Array} userIds The users to fetch.
  * @param {bool} forceDownload Always download the keys even if cached.
  *
  * @return {Promise} A promise which resolves to a map userId->deviceId->{@link
  * module:crypto/deviceinfo|DeviceInfo}.
  */
-Crypto.prototype.downloadKeys = function(userIds, forceDownload) {
-    var self = this;
-
-    // map from userid -> deviceid -> DeviceInfo
-    var stored = {};
-    function storeDev(userId, dev) {
-        stored[userId][dev.deviceId] = dev;
-    }
-
-    // list of userids we need to download keys for
-    var downloadUsers = [];
-
-    if (forceDownload) {
-        downloadUsers = userIds;
-    } else {
-        for (var i = 0; i < userIds.length; ++i) {
-            var userId = userIds[i];
-            var devices = this.getStoredDevicesForUser(userId);
-
-            if (!devices) {
-                downloadUsers.push(userId);
-            } else {
-                stored[userId] = {};
-                devices.map(storeDev.bind(null, userId));
-            }
-        }
-    }
-
-    if (downloadUsers.length === 0) {
-        return q(stored);
-    }
-
-    var r = this._doKeyDownloadForUsers(downloadUsers);
-    var promises = [];
-    downloadUsers.map(function(u) {
-        promises.push(r[u].catch(function(e) {
-            console.warn('Error downloading keys for user ' + u + ':', e);
-        }).then(function() {
-            stored[u] = {};
-            var devices = self.getStoredDevicesForUser(u) || [];
-            devices.map(storeDev.bind(null, u));
-        }));
-    });
-
-    return q.all(promises).then(function() {
-        return stored;
-    });
+Crypto.prototype.downloadKeys = function (userIds, forceDownload) {
+    return this._deviceList.downloadKeys(userIds, forceDownload);
 };
 
 /**
- * @param {string[]} downloadUsers list of userIds
- *
- * @return {Object a map from userId to a promise for a result for that user
- */
-Crypto.prototype._doKeyDownloadForUsers = function(downloadUsers) {
-    var self = this;
-
-    console.log('Starting key download for ' + downloadUsers);
-
-    var deferMap = {};
-    var promiseMap = {};
-
-    downloadUsers.map(function(u) {
-        deferMap[u] = q.defer();
-        promiseMap[u] = deferMap[u].promise;
-    });
-
-    this._baseApis.downloadKeysForUsers(
-        downloadUsers
-    ).done(function(res) {
-        var dk = res.device_keys || {};
-
-        for (var i = 0; i < downloadUsers.length; ++i) {
-            var userId = downloadUsers[i];
-            var deviceId;
-
-            console.log('got keys for ' + userId + ':', dk[userId]);
-
-            if (!dk[userId]) {
-                // no result for this user
-                var err = 'Unknown';
-                // TODO: do something with res.failures
-                deferMap[userId].reject(err);
-                continue;
-            }
-
-            // map from deviceid -> deviceinfo for this user
-            var userStore = {};
-            var devs = self._sessionStore.getEndToEndDevicesForUser(userId);
-            if (devs) {
-                for (deviceId in devs) {
-                    if (devs.hasOwnProperty(deviceId)) {
-                        var d = DeviceInfo.fromStorage(devs[deviceId], deviceId);
-                        userStore[deviceId] = d;
-                    }
-                }
-            }
-
-            _updateStoredDeviceKeysForUser(
-                self._olmDevice, userId, userStore, dk[userId]
-            );
-
-            // update the session store
-            var storage = {};
-            for (deviceId in userStore) {
-                if (!userStore.hasOwnProperty(deviceId)) {
-                    continue;
-                }
-
-                storage[deviceId] = userStore[deviceId].toStorage();
-            }
-            self._sessionStore.storeEndToEndDevicesForUser(
-                userId, storage
-            );
-
-            deferMap[userId].resolve();
-        }
-    }, function(err) {
-        downloadUsers.map(function(u) {
-            deferMap[u].reject(err);
-        });
-    });
-
-    return promiseMap;
-};
-
-function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore,
-                                        userResult) {
-    var updated = false;
-
-    // remove any devices in the store which aren't in the response
-    for (var deviceId in userStore) {
-        if (!userStore.hasOwnProperty(deviceId)) {
-            continue;
-        }
-
-        if (!(deviceId in userResult)) {
-            console.log("Device " + userId + ":" + deviceId +
-                        " has been removed");
-            delete userStore[deviceId];
-            updated = true;
-        }
-    }
-
-    for (deviceId in userResult) {
-        if (!userResult.hasOwnProperty(deviceId)) {
-            continue;
-        }
-
-        var deviceResult = userResult[deviceId];
-
-        // check that the user_id and device_id in the response object are
-        // correct
-        if (deviceResult.user_id !== userId) {
-            console.warn("Mismatched user_id " + deviceResult.user_id +
-                         " in keys from " + userId + ":" + deviceId);
-            continue;
-        }
-        if (deviceResult.device_id !== deviceId) {
-            console.warn("Mismatched device_id " + deviceResult.device_id +
-                         " in keys from " + userId + ":" + deviceId);
-            continue;
-        }
-
-        if (_storeDeviceKeys(_olmDevice, userStore, deviceResult)) {
-            updated = true;
-        }
-    }
-
-    return updated;
-}
-
-/*
- * Process a device in a /query response, and add it to the userStore
- *
- * returns true if a change was made, else false
- */
-function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
-    if (!deviceResult.keys) {
-        // no keys?
-        return false;
-    }
-
-    var deviceId = deviceResult.device_id;
-    var userId = deviceResult.user_id;
-
-    var signKeyId = "ed25519:" + deviceId;
-    var signKey = deviceResult.keys[signKeyId];
-    if (!signKey) {
-        console.log("Device " + userId + ":" + deviceId +
-                    " has no ed25519 key");
-        return false;
-    }
-
-    var unsigned = deviceResult.unsigned || {};
-
-    try {
-        olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey);
-    } catch (e) {
-        console.log("Unable to verify signature on device " +
-                    userId + ":" + deviceId + ":", e);
-        return false;
-    }
-
-    // DeviceInfo
-    var deviceStore;
-
-    if (deviceId in userStore) {
-        // already have this device.
-        deviceStore = userStore[deviceId];
-
-        if (deviceStore.getFingerprint() != signKey) {
-            // this should only happen if the list has been MITMed; we are
-            // best off sticking with the original keys.
-            //
-            // Should we warn the user about it somehow?
-            console.warn("Ed25519 key for device" + userId + ": " +
-                         deviceId + " has changed");
-            return false;
-        }
-    } else {
-        userStore[deviceId] = deviceStore = new DeviceInfo(deviceId);
-    }
-
-    deviceStore.keys = deviceResult.keys || {};
-    deviceStore.algorithms = deviceResult.algorithms || [];
-    deviceStore.unsigned = unsigned;
-    return true;
-}
-
-/**
  * Get the stored device keys for a user id
  *
  * @param {string} userId the user to list keys for.
  *
- * @return {module:crypto/deviceinfo[]?} list of devices, or null if we haven't
+ * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't
  * managed to get a list of devices for this user yet.
  */
-Crypto.prototype.getStoredDevicesForUser = function(userId) {
-    var devs = this._sessionStore.getEndToEndDevicesForUser(userId);
-    if (!devs) {
-        return null;
-    }
-    var res = [];
-    for (var deviceId in devs) {
-        if (devs.hasOwnProperty(deviceId)) {
-            res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId));
-        }
-    }
-    return res;
+Crypto.prototype.getStoredDevicesForUser = function (userId) {
+    return this._deviceList.getStoredDevicesForUser(userId);
 };
 
 /**
  * Get the stored keys for a single device
  *
  * @param {string} userId
  * @param {string} deviceId
  *
- * @return {module:crypto/deviceinfo?} list of devices, or undefined
+ * @return {module:crypto/deviceinfo?} device, or undefined
  * if we don't know about this device
  */
-Crypto.prototype.getStoredDevice = function(userId, deviceId) {
-    var devs = this._sessionStore.getEndToEndDevicesForUser(userId);
-    if (!devs || !devs[deviceId]) {
-        return undefined;
-    }
-    return DeviceInfo.fromStorage(devs[deviceId], deviceId);
+Crypto.prototype.getStoredDevice = function (userId, deviceId) {
+    return this._deviceList.getStoredDevice(userId, deviceId);
 };
 
 /**
- * List the stored device keys for a user id
- *
- * @deprecated prefer {@link module:crypto#getStoredDevicesForUser}
- *
- * @param {string} userId the user to list keys for.
+ * Save the device list, if necessary
  *
- * @return {object[]} list of devices with "id", "verified", "blocked",
- *    "key", and "display_name" parameters.
- */
-Crypto.prototype.listDeviceKeys = function(userId) {
-    var devices = this.getStoredDevicesForUser(userId) || [];
-
-    var result = [];
-
-    for (var i = 0; i < devices.length; ++i) {
-        var device = devices[i];
-        var ed25519Key = device.getFingerprint();
-        if (ed25519Key) {
-            result.push({
-                id: device.deviceId,
-                key: ed25519Key,
-                verified: Boolean(device.isVerified()),
-                blocked: Boolean(device.isBlocked()),
-                display_name: device.getDisplayName(),
-            });
-        }
-    }
-
-    // sort by deviceid
-    result.sort(function(a, b) {
-        if (a.deviceId < b.deviceId) { return -1; }
-        if (a.deviceId > b.deviceId) { return 1; }
-        return 0;
-    });
-
-    return result;
-};
-
-/**
- * Find a device by curve25519 identity key
+ * @param {integer} delay Time in ms before which the save actually happens.
+ *     By default, the save is delayed for a short period in order to batch
+ *     multiple writes, but this behaviour can be disabled by passing 0.
  *
- * @param {string} userId     owner of the device
- * @param {string} algorithm  encryption algorithm
- * @param {string} sender_key curve25519 key to match
- *
- * @return {module:crypto/deviceinfo?}
+ * @return {Promise<bool>} true if the data was saved, false if
+ *     it was not (eg. because no changes were pending). The promise
+ *     will only resolve once the data is saved, so may take some time
+ *     to resolve.
  */
-Crypto.prototype.getDeviceByIdentityKey = function(userId, algorithm, sender_key) {
-    if (
-        algorithm !== olmlib.OLM_ALGORITHM &&
-        algorithm !== olmlib.MEGOLM_ALGORITHM
-    ) {
-        // we only deal in olm keys
-        return null;
-    }
-
-    var devices = this._sessionStore.getEndToEndDevicesForUser(userId);
-    if (!devices) {
-        return null;
-    }
-
-    for (var deviceId in devices) {
-        if (!devices.hasOwnProperty(deviceId)) {
-            continue;
-        }
-
-        var device = devices[deviceId];
-        for (var keyId in device.keys) {
-            if (!device.keys.hasOwnProperty(keyId)) {
-                continue;
-            }
-            if (keyId.indexOf("curve25519:") !== 0) {
-                continue;
-            }
-            var deviceKey = device.keys[keyId];
-            if (deviceKey == sender_key) {
-                return DeviceInfo.fromStorage(device, deviceId);
-            }
-        }
-    }
-
-    // doesn't match a known device
-    return null;
+Crypto.prototype.saveDeviceList = function (delay) {
+    return this._deviceList.saveIfDirty(delay);
 };
 
-
 /**
  * Update the blocked/verified state of the given device
  *
  * @param {string} userId owner of the device
  * @param {string} deviceId unique identifier for the device
  *
  * @param {?boolean} verified whether to mark the device as verified. Null to
  *     leave unchanged.
  *
  * @param {?boolean} blocked whether to mark the device as blocked. Null to
  *      leave unchanged.
+ *
+ * @param {?boolean} known whether to mark that the user has been made aware of
+ *      the existence of this device. Null to leave unchanged
+ *
+ * @return {Promise<module:crypto/deviceinfo>} updated DeviceInfo
  */
-Crypto.prototype.setDeviceVerification = function(userId, deviceId, verified, blocked) {
-    var devices = this._sessionStore.getEndToEndDevicesForUser(userId);
+Crypto.prototype.setDeviceVerification = async function (userId, deviceId, verified, blocked, known) {
+    const devices = this._deviceList.getRawStoredDevicesForUser(userId);
     if (!devices || !devices[deviceId]) {
         throw new Error("Unknown device " + userId + ":" + deviceId);
     }
 
-    var dev = devices[deviceId];
-    var verificationStatus = dev.verified;
+    const dev = devices[deviceId];
+    let verificationStatus = dev.verified;
 
     if (verified) {
         verificationStatus = DeviceVerification.VERIFIED;
     } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) {
         verificationStatus = DeviceVerification.UNVERIFIED;
     }
 
     if (blocked) {
         verificationStatus = DeviceVerification.BLOCKED;
     } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) {
         verificationStatus = DeviceVerification.UNVERIFIED;
     }
 
-    if (dev.verified === verificationStatus) {
-        return;
+    let knownStatus = dev.known;
+    if (known !== null && known !== undefined) {
+        knownStatus = known;
     }
-    dev.verified = verificationStatus;
-    this._sessionStore.storeEndToEndDevicesForUser(userId, devices);
+
+    if (dev.verified !== verificationStatus || dev.known !== knownStatus) {
+        dev.verified = verificationStatus;
+        dev.known = knownStatus;
+        this._deviceList.storeDevicesForUser(userId, devices);
+        this._deviceList.saveIfDirty();
+    }
+    return DeviceInfo.fromStorage(dev, deviceId);
 };
 
+Crypto.prototype.requestVerification = function (userId, methods, devices) {
+    if (!methods) {
+        // .keys() returns an iterator, so we need to explicitly turn it into an array
+        methods = [...this._verificationMethods.keys()];
+    }
+    if (!devices) {
+        devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId));
+    }
+    if (!this._verificationTransactions.has(userId)) {
+        this._verificationTransactions.set(userId, new Map());
+    }
+
+    const transactionId = (0, _randomstring.randomString)(32);
+
+    const promise = new _bluebird2.default((resolve, reject) => {
+        this._verificationTransactions.get(userId).set(transactionId, {
+            request: {
+                methods: methods,
+                devices: devices,
+                resolve: resolve,
+                reject: reject
+            }
+        });
+    });
+
+    const message = {
+        transaction_id: transactionId,
+        from_device: this._baseApis.deviceId,
+        methods: methods,
+        timestamp: Date.now()
+    };
+    const msgMap = {};
+    for (const deviceId of devices) {
+        msgMap[deviceId] = message;
+    }
+    this._baseApis.sendToDevice("m.key.verification.request", { [userId]: msgMap });
+
+    return promise;
+};
+
+Crypto.prototype.beginKeyVerification = function (method, userId, deviceId, transactionId) {
+    if (!this._verificationTransactions.has(userId)) {
+        this._verificationTransactions.set(userId, new Map());
+    }
+    transactionId = transactionId || (0, _randomstring.randomString)(32);
+    if (method instanceof Array) {
+        if (method.length !== 2 || !this._verificationMethods.has(method[0]) || !this._verificationMethods.has(method[1])) {
+            throw (0, _Error.newUnknownMethodError)();
+        }
+        /*
+        return new TwoPartVerification(
+            this._verificationMethods[method[0]],
+            this._verificationMethods[method[1]],
+            userId, deviceId, transactionId,
+        );
+        */
+    } else if (this._verificationMethods.has(method)) {
+        const verifier = new (this._verificationMethods.get(method))(this._baseApis, userId, deviceId, transactionId);
+        if (!this._verificationTransactions.get(userId).has(transactionId)) {
+            this._verificationTransactions.get(userId).set(transactionId, {});
+        }
+        this._verificationTransactions.get(userId).get(transactionId).verifier = verifier;
+        return verifier;
+    } else {
+        throw (0, _Error.newUnknownMethodError)();
+    }
+};
 
 /**
  * Get information on the active olm sessions with a user
  * <p>
  * Returns a map from device id to an object with keys 'deviceIdKey' (the
  * device's curve25519 identity key) and 'sessions' (an array of objects in the
  * same format as that returned by
  * {@link module:crypto/OlmDevice#getSessionInfoForDevice}).
  * <p>
  * This method is provided for debugging purposes.
  *
  * @param {string} userId id of user to inspect
  *
- * @return {Object.<string, {deviceIdKey: string, sessions: object[]}>}
+ * @return {Promise<Object.<string, {deviceIdKey: string, sessions: object[]}>>}
  */
-Crypto.prototype.getOlmSessionsForUser = function(userId) {
-    var devices = this.getStoredDevicesForUser(userId) || [];
-    var result = {};
-    for (var j = 0; j < devices.length; ++j) {
-        var device = devices[j];
-        var deviceKey = device.getIdentityKey();
-        var sessions = this._olmDevice.getSessionInfoForDevice(deviceKey);
+Crypto.prototype.getOlmSessionsForUser = async function (userId) {
+    const devices = this.getStoredDevicesForUser(userId) || [];
+    const result = {};
+    for (let j = 0; j < devices.length; ++j) {
+        const device = devices[j];
+        const deviceKey = device.getIdentityKey();
+        const sessions = await this._olmDevice.getSessionInfoForDevice(deviceKey);
 
         result[device.deviceId] = {
             deviceIdKey: deviceKey,
-            sessions: sessions,
+            sessions: sessions
         };
     }
     return result;
 };
 
-
 /**
  * Get the device which sent an event
  *
  * @param {module:models/event.MatrixEvent} event event to be checked
  *
  * @return {module:crypto/deviceinfo?}
  */
-Crypto.prototype.getEventSenderDeviceInfo = function(event) {
-    var sender_key = event.getSenderKey();
-    var algorithm = event.getWireContent().algorithm;
+Crypto.prototype.getEventSenderDeviceInfo = function (event) {
+    const senderKey = event.getSenderKey();
+    const algorithm = event.getWireContent().algorithm;
 
-    if (!sender_key || !algorithm) {
+    if (!senderKey || !algorithm) {
         return null;
     }
 
-    // sender_key is the Curve25519 identity key of the device which the event
+    const forwardingChain = event.getForwardingCurve25519KeyChain();
+    if (forwardingChain.length > 0) {
+        // we got this event from somewhere else
+        // TODO: check if we can trust the forwarders.
+        return null;
+    }
+
+    // senderKey is the Curve25519 identity key of the device which the event
     // was sent from. In the case of Megolm, it's actually the Curve25519
     // identity key of the device which set up the Megolm session.
 
-    var device = this.getDeviceByIdentityKey(
-        event.getSender(), algorithm, sender_key
-    );
+    const device = this._deviceList.getDeviceByIdentityKey(algorithm, senderKey);
 
     if (device === null) {
         // we haven't downloaded the details of this device yet.
         return null;
     }
 
     // so far so good, but now we need to check that the sender of this event
     // hadn't advertised someone else's Curve25519 key as their own. We do that
     // by checking the Ed25519 claimed by the event (or, in the case of megolm,
     // the event which set up the megolm session), to check that it matches the
     // fingerprint of the purported sending device.
     //
     // (see https://github.com/vector-im/vector-web/issues/2215)
 
-    var claimedKey = event.getKeysClaimed().ed25519;
+    const claimedKey = event.getClaimedEd25519Key();
     if (!claimedKey) {
-        console.warn("Event " + event.getId() + " claims no ed25519 key: " +
-                     "cannot verify sending device");
+        _logger2.default.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device");
         return null;
     }
 
     if (claimedKey !== device.getFingerprint()) {
-        console.warn(
-            "Event " + event.getId() + " claims ed25519 key " + claimedKey +
-                "but sender device has key " + device.getFingerprint());
+        _logger2.default.warn("Event " + event.getId() + " claims ed25519 key " + claimedKey + "but sender device has key " + device.getFingerprint());
         return null;
     }
 
     return device;
 };
 
+/**
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
+ *
+ * @param {string} roomId The ID of the room to discard the session for
+ *
+ * This should not normally be necessary.
+ */
+Crypto.prototype.forceDiscardSession = function (roomId) {
+    const alg = this._roomEncryptors[roomId];
+    if (alg === undefined) throw new Error("Room not encrypted");
+    if (alg.forceDiscardSession === undefined) {
+        throw new Error("Room encryption algorithm doesn't support session discarding");
+    }
+    alg.forceDiscardSession();
+};
 
 /**
- * Configure a room to use encryption (ie, save a flag in the sessionstore).
+ * Configure a room to use encryption (ie, save a flag in the cryptoStore).
  *
  * @param {string} roomId The room ID to enable encryption in.
+ *
  * @param {object} config The encryption config for the room.
+ *
+ * @param {boolean=} inhibitDeviceQuery true to suppress device list query for
+ *   users in the room (for now). In case lazy loading is enabled,
+ *   the device query is always inhibited as the members are not tracked.
  */
-Crypto.prototype.setRoomEncryption = function(roomId, config) {
-    // if we already have encryption in this room, we should ignore this event
-    // (for now at least. maybe we should alert the user somehow?)
-    var existingConfig = this._sessionStore.getEndToEndRoom(roomId);
+Crypto.prototype.setRoomEncryption = async function (roomId, config, inhibitDeviceQuery) {
+    // ignore crypto events with no algorithm defined
+    // This will happen if a crypto event is redacted before we fetch the room state
+    // It would otherwise just throw later as an unknown algorithm would, but we may
+    // as well catch this here
+    if (!config.algorithm) {
+        console.log("Ignoring setRoomEncryption with no algorithm");
+        return;
+    }
+
+    // if state is being replayed from storage, we might already have a configuration
+    // for this room as they are persisted as well.
+    // We just need to make sure the algorithm is initialized in this case.
+    // However, if the new config is different,
+    // we should bail out as room encryption can't be changed once set.
+    const existingConfig = this._roomList.getRoomEncryption(roomId);
     if (existingConfig) {
         if (JSON.stringify(existingConfig) != JSON.stringify(config)) {
-            console.error("Ignoring m.room.encryption event which requests " +
-                          "a change of config in " + roomId);
+            _logger2.default.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId);
             return;
         }
     }
+    // if we already have encryption in this room, we should ignore this event,
+    // as it would reset the encryption algorithm.
+    // This is at least expected to be called twice, as sync calls onCryptoEvent
+    // for both the timeline and state sections in the /sync response,
+    // the encryption event would appear in both.
+    // If it's called more than twice though,
+    // it signals a bug on client or server.
+    const existingAlg = this._roomEncryptors[roomId];
+    if (existingAlg) {
+        return;
+    }
 
-    var AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm];
+    // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption
+    // because it first stores in memory. We should await the promise only
+    // after all the in-memory state (_roomEncryptors and _roomList) has been updated
+    // to avoid races when calling this method multiple times. Hence keep a hold of the promise.
+    let storeConfigPromise = null;
+    if (!existingConfig) {
+        storeConfigPromise = this._roomList.setRoomEncryption(roomId, config);
+    }
+
+    const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm];
     if (!AlgClass) {
         throw new Error("Unable to encrypt with " + config.algorithm);
     }
 
-    // remove spurious keys
-    config = {
-        algorithm: config.algorithm,
-    };
-    this._sessionStore.storeEndToEndRoom(roomId, config);
-
-    var alg = new AlgClass({
+    const alg = new AlgClass({
         userId: this._userId,
         deviceId: this._deviceId,
         crypto: this,
         olmDevice: this._olmDevice,
         baseApis: this._baseApis,
         roomId: roomId,
-        config: config,
+        config: config
     });
     this._roomEncryptors[roomId] = alg;
+
+    if (storeConfigPromise) {
+        await storeConfigPromise;
+    }
+
+    if (!this._lazyLoadMembers) {
+        _logger2.default.log("Enabling encryption in " + roomId + "; " + "starting to track device lists for all users therein");
+
+        await this.trackRoomDevices(roomId);
+        // TODO: this flag is only not used from MatrixClient::setRoomEncryption
+        // which is never used (inside riot at least)
+        // but didn't want to remove it as it technically would
+        // be a breaking change.
+        if (!this.inhibitDeviceQuery) {
+            this._deviceList.refreshOutdatedDeviceLists();
+        }
+    } else {
+        _logger2.default.log("Enabling encryption in " + roomId);
+    }
 };
 
+/**
+ * Make sure we are tracking the device lists for all users in this room.
+ *
+ * @param {string} roomId The room ID to start tracking devices in.
+ * @returns {Promise} when all devices for the room have been fetched and marked to track
+ */
+Crypto.prototype.trackRoomDevices = function (roomId) {
+    const trackMembers = async () => {
+        // not an encrypted room
+        if (!this._roomEncryptors[roomId]) {
+            return;
+        }
+        const room = this._clientStore.getRoom(roomId);
+        if (!room) {
+            throw new Error(`Unable to start tracking devices in unknown room ${roomId}`);
+        }
+        _logger2.default.log(`Starting to track devices for room ${roomId} ...`);
+        const members = await room.getEncryptionTargetMembers();
+        members.forEach(m => {
+            this._deviceList.startTrackingDeviceList(m.userId);
+        });
+    };
+
+    let promise = this._roomDeviceTrackingState[roomId];
+    if (!promise) {
+        promise = trackMembers();
+        this._roomDeviceTrackingState[roomId] = promise;
+    }
+    return promise;
+};
 
 /**
  * @typedef {Object} module:crypto~OlmSessionResult
  * @property {module:crypto/deviceinfo} device  device info
  * @property {string?} sessionId base64 olm session id; null if no session
  *    could be established
  */
 
@@ -807,385 +1030,1046 @@ Crypto.prototype.setRoomEncryption = fun
  * the given users.
  *
  * @param {string[]} users list of user ids
  *
  * @return {module:client.Promise} resolves once the sessions are complete, to
  *    an Object mapping from userId to deviceId to
  *    {@link module:crypto~OlmSessionResult}
  */
-Crypto.prototype.ensureOlmSessionsForUsers = function(users) {
-    var devicesByUser = {};
+Crypto.prototype.ensureOlmSessionsForUsers = function (users) {
+    const devicesByUser = {};
 
-    for (var i = 0; i < users.length; ++i) {
-        var userId = users[i];
+    for (let i = 0; i < users.length; ++i) {
+        const userId = users[i];
         devicesByUser[userId] = [];
 
-        var devices = this.getStoredDevicesForUser(userId) || [];
-        for (var j = 0; j < devices.length; ++j) {
-            var deviceInfo = devices[j];
+        const devices = this.getStoredDevicesForUser(userId) || [];
+        for (let j = 0; j < devices.length; ++j) {
+            const deviceInfo = devices[j];
 
-            var key = deviceInfo.getIdentityKey();
+            const key = deviceInfo.getIdentityKey();
             if (key == this._olmDevice.deviceCurve25519Key) {
                 // don't bother setting up session to ourself
                 continue;
             }
             if (deviceInfo.verified == DeviceVerification.BLOCKED) {
                 // don't bother setting up sessions with blocked users
                 continue;
             }
 
             devicesByUser[userId].push(deviceInfo);
         }
     }
 
-    return olmlib.ensureOlmSessionsForDevices(
-        this._olmDevice, this._baseApis, devicesByUser
-    );
+    return olmlib.ensureOlmSessionsForDevices(this._olmDevice, this._baseApis, devicesByUser);
+};
+
+/**
+ * Get a list containing all of the room keys
+ *
+ * @return {module:crypto/OlmDevice.MegolmSessionData[]} a list of session export objects
+ */
+Crypto.prototype.exportRoomKeys = async function () {
+    const exportedSessions = [];
+    await this._cryptoStore.doTxn('readonly', [_indexeddbCryptoStore2.default.STORE_INBOUND_GROUP_SESSIONS], txn => {
+        this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, s => {
+            if (s === null) return;
+
+            const sess = this._olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData);
+            delete sess.first_known_index;
+            sess.algorithm = olmlib.MEGOLM_ALGORITHM;
+            exportedSessions.push(sess);
+        });
+    });
+
+    return exportedSessions;
+};
+
+/**
+ * Import a list of room keys previously exported by exportRoomKeys
+ *
+ * @param {Object[]} keys a list of session export objects
+ * @return {module:client.Promise} a promise which resolves once the keys have been imported
+ */
+Crypto.prototype.importRoomKeys = function (keys) {
+    return _bluebird2.default.map(keys, key => {
+        if (!key.room_id || !key.algorithm) {
+            _logger2.default.warn("ignoring room key entry with missing fields", key);
+            return null;
+        }
+
+        const alg = this._getRoomDecryptor(key.room_id, key.algorithm);
+        return alg.importRoomKey(key);
+    });
+};
+
+/**
+ * Schedules sending all keys waiting to be sent to the backup, if not already
+ * scheduled. Retries if necessary.
+ *
+ * @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay.
+ */
+Crypto.prototype.scheduleKeyBackupSend = async function (maxDelay = 10000) {
+    if (this._sendingBackups) return;
+
+    this._sendingBackups = true;
+
+    try {
+        // wait between 0 and `maxDelay` seconds, to avoid backup
+        // requests from different clients hitting the server all at
+        // the same time when a new key is sent
+        const delay = Math.random() * maxDelay;
+        await _bluebird2.default.delay(delay);
+        let numFailures = 0; // number of consecutive failures
+        while (1) {
+            if (!this.backupKey) {
+                return;
+            }
+            try {
+                const numBackedUp = await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
+                if (numBackedUp === 0) {
+                    // no sessions left needing backup: we're done
+                    return;
+                }
+                numFailures = 0;
+            } catch (err) {
+                numFailures++;
+                _logger2.default.log("Key backup request failed", err);
+                if (err.data) {
+                    if (err.data.errcode == 'M_NOT_FOUND' || err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION') {
+                        // Re-check key backup status on error, so we can be
+                        // sure to present the current situation when asked.
+                        await this.checkKeyBackup();
+                        // Backup version has changed or this backup version
+                        // has been deleted
+                        this.emit("crypto.keyBackupFailed", err.data.errcode);
+                        throw err;
+                    }
+                }
+            }
+            if (numFailures) {
+                // exponential backoff if we have failures
+                await _bluebird2.default.delay(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
+            }
+        }
+    } finally {
+        this._sendingBackups = false;
+    }
 };
 
 /**
- * Whether encryption is enabled for a room.
- * @param {string} roomId the room id to query.
- * @return {bool} whether encryption is enabled.
+ * Take some e2e keys waiting to be backed up and send them
+ * to the backup.
+ *
+ * @param {integer} limit Maximum number of keys to back up
+ * @returns {integer} Number of sessions backed up
  */
-Crypto.prototype.isRoomEncrypted = function(roomId) {
-    return Boolean(this._roomEncryptors[roomId]);
+Crypto.prototype._backupPendingKeys = async function (limit) {
+    const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit);
+    if (!sessions.length) {
+        return 0;
+    }
+
+    let remaining = await this._cryptoStore.countSessionsNeedingBackup();
+    this.emit("crypto.keyBackupSessionsRemaining", remaining);
+
+    const data = {};
+    for (const session of sessions) {
+        const roomId = session.sessionData.room_id;
+        if (data[roomId] === undefined) {
+            data[roomId] = { sessions: {} };
+        }
+
+        const sessionData = await this._olmDevice.exportInboundGroupSession(session.senderKey, session.sessionId, session.sessionData);
+        sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
+        delete sessionData.session_id;
+        delete sessionData.room_id;
+        const firstKnownIndex = sessionData.first_known_index;
+        delete sessionData.first_known_index;
+        const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
+
+        const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length;
+
+        const device = this._deviceList.getDeviceByIdentityKey(olmlib.MEGOLM_ALGORITHM, session.senderKey);
+
+        data[roomId]['sessions'][session.sessionId] = {
+            first_message_index: firstKnownIndex,
+            forwarded_count: forwardedCount,
+            is_verified: !!(device && device.isVerified()),
+            session_data: encrypted
+        };
+    }
+
+    await this._baseApis.sendKeyBackup(undefined, undefined, this.backupInfo.version, { rooms: data });
+
+    await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
+    remaining = await this._cryptoStore.countSessionsNeedingBackup();
+    this.emit("crypto.keyBackupSessionsRemaining", remaining);
+
+    return sessions.length;
+};
+
+Crypto.prototype.backupGroupSession = async function (roomId, senderKey, forwardingCurve25519KeyChain, sessionId, sessionKey, keysClaimed, exportFormat) {
+    if (!this.backupInfo) {
+        throw new Error("Key backups are not enabled");
+    }
+
+    await this._cryptoStore.markSessionsNeedingBackup([{
+        senderKey: senderKey,
+        sessionId: sessionId
+    }]);
+
+    // don't wait for this to complete: it will delay so
+    // happens in the background
+    this.scheduleKeyBackupSend();
 };
 
 /**
- * Encrypt an event according to the configuration of the room, if necessary.
+ * Marks all group sessions as needing to be backed up and schedules them to
+ * upload in the background as soon as possible.
+ */
+Crypto.prototype.scheduleAllGroupSessionsForBackup = async function () {
+    await this.flagAllGroupSessionsForBackup();
+
+    // Schedule keys to upload in the background as soon as possible.
+    this.scheduleKeyBackupSend(0 /* maxDelay */);
+};
+
+/**
+ * Marks all group sessions as needing to be backed up without scheduling
+ * them to upload in the background.
+ * @returns {Promise<int>} Resolves to the number of sessions requiring a backup.
+ */
+Crypto.prototype.flagAllGroupSessionsForBackup = async function () {
+    await this._cryptoStore.doTxn('readwrite', [_indexeddbCryptoStore2.default.STORE_INBOUND_GROUP_SESSIONS, _indexeddbCryptoStore2.default.STORE_BACKUP], txn => {
+        this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, session => {
+            if (session !== null) {
+                this._cryptoStore.markSessionsNeedingBackup([session], txn);
+            }
+        });
+    });
+
+    const remaining = await this._cryptoStore.countSessionsNeedingBackup();
+    this.emit("crypto.keyBackupSessionsRemaining", remaining);
+    return remaining;
+};
+
+/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307
+/**
+ * Encrypt an event according to the configuration of the room.
  *
  * @param {module:models/event.MatrixEvent} event  event to be sent
  *
- * @param {module:models/room?} room destination room. Null if the destination
- *     is not a room we have seen over the sync pipe.
+ * @param {module:models/room} room destination room.
  *
  * @return {module:client.Promise?} Promise which resolves when the event has been
  *     encrypted, or null if nothing was needed
  */
-Crypto.prototype.encryptEventIfNeeded = function(event, room) {
-    if (event.isEncrypted()) {
-        // this event has already been encrypted; this happens if the
-        // encryption step succeeded, but the send step failed on the first
-        // attempt.
-        return null;
-    }
-
+/* eslint-enable valid-jsdoc */
+Crypto.prototype.encryptEvent = async function (event, room) {
     if (!room) {
         throw new Error("Cannot send encrypted messages in unknown rooms");
     }
 
-    var roomId = event.getRoomId();
-
-    var alg = this._roomEncryptors[roomId];
-    if (!alg) {
-        // not encrypting messages in this room
+    const roomId = event.getRoomId();
 
-        // check that the HS hasn't hidden the crypto event
-        if (this._sessionStore.getEndToEndRoom(roomId)) {
-            throw new Error(
-                "Room was previously configured to use encryption, but is " +
-                "no longer. Perhaps the homeserver is hiding the " +
-                "configuration event."
-            );
-        }
-        return null;
+    const alg = this._roomEncryptors[roomId];
+    if (!alg) {
+        // MatrixClient has already checked that this room should be encrypted,
+        // so this is an unexpected situation.
+        throw new Error("Room was previously configured to use encryption, but is " + "no longer. Perhaps the homeserver is hiding the " + "configuration event.");
     }
 
-    // We can claim and prove ownership of all our device keys in the local
-    // echo of the event since we know that all the local echos come from
-    // this device.
-    var myKeys = {
-        curve25519: this._olmDevice.deviceCurve25519Key,
-        ed25519: this._olmDevice.deviceEd25519Key,
-    };
+    if (!this._roomDeviceTrackingState[roomId]) {
+        this.trackRoomDevices(roomId);
+    }
+    // wait for all the room devices to be loaded
+    await this._roomDeviceTrackingState[roomId];
 
-    return alg.encryptMessage(
-        room, event.getType(), event.getContent()
-    ).then(function(encryptedContent) {
-        event.makeEncrypted("m.room.encrypted", encryptedContent, myKeys);
-    });
+    let content = event.getContent();
+    // If event has an m.relates_to then we need
+    // to put this on the wrapping event instead
+    const mRelatesTo = content['m.relates_to'];
+    if (mRelatesTo) {
+        // Clone content here so we don't remove `m.relates_to` from the local-echo
+        content = Object.assign({}, content);
+        delete content['m.relates_to'];
+    }
+
+    const encryptedContent = await alg.encryptMessage(room, event.getType(), content);
+
+    if (mRelatesTo) {
+        encryptedContent['m.relates_to'] = mRelatesTo;
+    }
+
+    event.makeEncrypted("m.room.encrypted", encryptedContent, this._olmDevice.deviceCurve25519Key, this._olmDevice.deviceEd25519Key);
 };
 
 /**
  * Decrypt a received event
  *
  * @param {MatrixEvent} event
  *
- * @raises {algorithms.DecryptionError} if there is a problem decrypting the event
+ * @return {Promise<module:crypto~EventDecryptionResult>} resolves once we have
+ *  finished decrypting. Rejects with an `algorithms.DecryptionError` if there
+ *  is a problem decrypting the event.
  */
-Crypto.prototype.decryptEvent = function(event) {
-    var content = event.getWireContent();
-    var alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm);
-    alg.decryptEvent(event);
+Crypto.prototype.decryptEvent = function (event) {
+    if (event.isRedacted()) {
+        return _bluebird2.default.resolve({
+            clearEvent: {
+                room_id: event.getRoomId(),
+                type: "m.room.message",
+                content: {}
+            }
+        });
+    }
+    const content = event.getWireContent();
+    const alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm);
+    return alg.decryptEvent(event);
 };
 
 /**
- * Send a "m.new_device" message to remind it that we exist and are a member
- * of a room.
- *
- * This is rate limited to send a message at most once an hour per desination.
+ * Handle the notification from /sync or /keys/changes that device lists have
+ * been changed.
  *
- * @param {string}  userId   The ID of the user to ping.
- * @param {string?} deviceId The ID of the device to ping. If null, all
- *     devices.
- * @param {string}  roomId   The ID of the room we want to remind them about.
+ * @param {Object} syncData Object containing sync tokens associated with this sync
+ * @param {Object} syncDeviceLists device_lists field from /sync, or response from
+ * /keys/changes
  */
-Crypto.prototype._sendPingToDevice = function(userId, deviceId, roomId) {
-    if (deviceId === null) {
-        deviceId = "*";
-    }
+Crypto.prototype.handleDeviceListChanges = async function (syncData, syncDeviceLists) {
+    // Initial syncs don't have device change lists. We'll either get the complete list
+    // of changes for the interval or will have invalidated everything in willProcessSync
+    if (!syncData.oldSyncToken) return;
 
-    var lastMessageTsMap = this._lastNewDeviceMessageTsByUserDeviceRoom;
-
-    var lastTsByDevice = lastMessageTsMap[userId];
-    if (!lastTsByDevice) {
-        lastTsByDevice = lastMessageTsMap[userId] = {};
-    }
+    // Here, we're relying on the fact that we only ever save the sync data after
+    // sucessfully saving the device list data, so we're guaranteed that the device
+    // list store is at least as fresh as the sync token from the sync store, ie.
+    // any device changes received in sync tokens prior to the 'next' token here
+    // have been processed and are reflected in the current device list.
+    // If we didn't make this assumption, we'd have to use the /keys/changes API
+    // to get key changes between the sync token in the device list and the 'old'
+    // sync token used here to make sure we didn't miss any.
+    await this._evalDeviceListChanges(syncDeviceLists);
+};
 
-    var lastTsByRoom = lastTsByDevice[deviceId];
-    if (!lastTsByRoom) {
-        lastTsByRoom = lastTsByDevice[deviceId] = {};
-    }
-
-    var lastTs = lastTsByRoom[roomId];
-    var timeNowMs = Date.now();
-    var oneHourMs = 1000 * 60 * 60;
-
-    if (lastTs !== undefined && lastTs + oneHourMs > timeNowMs) {
-        // rate-limiting
-        return;
-    }
+/**
+ * Send a request for some room keys, if we have not already done so
+ *
+ * @param {module:crypto~RoomKeyRequestBody} requestBody
+ * @param {Array<{userId: string, deviceId: string}>} recipients
+ * @param {boolean} resend whether to resend the key request if there is
+ *    already one
+ *
+ * @return {Promise} a promise that resolves when the key request is queued
+ */
+Crypto.prototype.requestRoomKey = function (requestBody, recipients, resend = false) {
+    return this._outgoingRoomKeyRequestManager.sendRoomKeyRequest(requestBody, recipients, resend).catch(e => {
+        // this normally means we couldn't talk to the store
+        _logger2.default.error('Error requesting key for event', e);
+    }).done();
+};
 
-    var content = {};
-    content[userId] = {};
-    content[userId][deviceId] = {
-        device_id: this._deviceId,
-        rooms: [roomId],
-    };
-
-    this._baseApis.sendToDevice(
-        "m.new_device", // OH HAI!
-        content
-    ).done();
-
-    lastTsByRoom[roomId] = timeNowMs;
+/**
+ * Cancel any earlier room key request
+ *
+ * @param {module:crypto~RoomKeyRequestBody} requestBody
+ *    parameters to match for cancellation
+ */
+Crypto.prototype.cancelRoomKeyRequest = function (requestBody) {
+    this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch(e => {
+        _logger2.default.warn("Error clearing pending room key requests", e);
+    }).done();
 };
 
 /**
  * handle an m.room.encryption event
  *
- * @private
  * @param {module:models/event.MatrixEvent} event encryption event
  */
-Crypto.prototype._onCryptoEvent = function(event) {
-    var roomId = event.getRoomId();
-    var content = event.getContent();
+Crypto.prototype.onCryptoEvent = async function (event) {
+    const roomId = event.getRoomId();
+    const content = event.getContent();
 
     try {
-        this.setRoomEncryption(roomId, content);
+        // inhibit the device list refresh for now - it will happen once we've
+        // finished processing the sync, in onSyncCompleted.
+        await this.setRoomEncryption(roomId, content, true);
     } catch (e) {
-        console.error("Error configuring encryption in room " + roomId +
-                      ":", e);
+        _logger2.default.error("Error configuring encryption in room " + roomId + ":", e);
+    }
+};
+
+/**
+ * Called before the result of a sync is procesed
+ *
+ * @param {Object} syncData  the data from the 'MatrixClient.sync' event
+ */
+Crypto.prototype.onSyncWillProcess = async function (syncData) {
+    if (!syncData.oldSyncToken) {
+        // If there is no old sync token, we start all our tracking from
+        // scratch, so mark everything as untracked. onCryptoEvent will
+        // be called for all e2e rooms during the processing of the sync,
+        // at which point we'll start tracking all the users of that room.
+        _logger2.default.log("Initial sync performed - resetting device tracking state");
+        this._deviceList.stopTrackingAllDeviceLists();
+        this._roomDeviceTrackingState = {};
+    }
+};
+
+/**
+ * handle the completion of a /sync
+ *
+ * This is called after the processing of each successful /sync response.
+ * It is an opportunity to do a batch process on the information received.
+ *
+ * @param {Object} syncData  the data from the 'MatrixClient.sync' event
+ */
+Crypto.prototype.onSyncCompleted = async function (syncData) {
+    const nextSyncToken = syncData.nextSyncToken;
+
+    this._deviceList.setSyncToken(syncData.nextSyncToken);
+    this._deviceList.saveIfDirty();
+
+    // catch up on any new devices we got told about during the sync.
+    this._deviceList.lastKnownSyncToken = nextSyncToken;
+
+    // we always track our own device list (for key backups etc)
+    this._deviceList.startTrackingDeviceList(this._userId);
+
+    this._deviceList.refreshOutdatedDeviceLists();
+
+    // we don't start uploading one-time keys until we've caught up with
+    // to-device messages, to help us avoid throwing away one-time-keys that we
+    // are about to receive messages for
+    // (https://github.com/vector-im/riot-web/issues/2782).
+    if (!syncData.catchingUp) {
+        _maybeUploadOneTimeKeys(this);
+        this._processReceivedRoomKeyRequests();
     }
 };
 
 /**
- * handle the completion of the initial sync.
+ * Trigger the appropriate invalidations and removes for a given
+ * device list
  *
- * Announces the new device.
- *
- * @private
- * @param {module:models/room[]} rooms list of rooms the client knows about
+ * @param {Object} deviceLists device_lists field from /sync, or response from
+ * /keys/changes
  */
-Crypto.prototype._onInitialSyncCompleted = function(rooms) {
-    this._initialSyncCompleted = true;
-
-    // catch up on any m.new_device events which arrived during the initial sync.
-    this._flushNewDeviceRequests();
-
-    if (this._sessionStore.getDeviceAnnounced()) {
-        return;
+Crypto.prototype._evalDeviceListChanges = async function (deviceLists) {
+    if (deviceLists.changed && Array.isArray(deviceLists.changed)) {
+        deviceLists.changed.forEach(u => {
+            this._deviceList.invalidateUserDeviceList(u);
+        });
     }
 
-    // we need to tell all the devices in all the rooms we are members of that
-    // we have arrived.
-    // build a list of rooms for each user.
-    var roomsByUser = {};
-    for (var i = 0; i < rooms.length; i++) {
-        var room = rooms[i];
+    if (deviceLists.left && Array.isArray(deviceLists.left) && deviceLists.left.length) {
+        // Check we really don't share any rooms with these users
+        // any more: the server isn't required to give us the
+        // exact correct set.
+        const e2eUserIds = new Set((await this._getTrackedE2eUsers()));
+
+        deviceLists.left.forEach(u => {
+            if (!e2eUserIds.has(u)) {
+                this._deviceList.stopTrackingDeviceList(u);
+            }
+        });
+    }
+};
 
+/**
+ * Get a list of all the IDs of users we share an e2e room with
+ * for which we are tracking devices already
+ *
+ * @returns {string[]} List of user IDs
+ */
+Crypto.prototype._getTrackedE2eUsers = async function () {
+    const e2eUserIds = [];
+    for (const room of this._getTrackedE2eRooms()) {
+        const members = await room.getEncryptionTargetMembers();
+        for (const member of members) {
+            e2eUserIds.push(member.userId);
+        }
+    }
+    return e2eUserIds;
+};
+
+/**
+ * Get a list of the e2e-enabled rooms we are members of,
+ * and for which we are already tracking the devices
+ *
+ * @returns {module:models.Room[]}
+ */
+Crypto.prototype._getTrackedE2eRooms = function () {
+    return this._clientStore.getRooms().filter(room => {
         // check for rooms with encryption enabled
-        var alg = this._roomEncryptors[room.roomId];
+        const alg = this._roomEncryptors[room.roomId];
         if (!alg) {
-            continue;
+            return false;
+        }
+        if (!this._roomDeviceTrackingState[room.roomId]) {
+            return false;
         }
 
         // ignore any rooms which we have left
-        var me = room.getMember(this._userId);
-        if (!me || (
-            me.membership !== "join" && me.membership !== "invite"
-        )) {
-            continue;
-        }
-
-        var members = room.getJoinedMembers();
-        for (var j = 0; j < members.length; j++) {
-            var m = members[j];
-            if (!roomsByUser[m.userId]) {
-                roomsByUser[m.userId] = [];
-            }
-            roomsByUser[m.userId].push(room.roomId);
-        }
-    }
+        const myMembership = room.getMyMembership();
+        return myMembership === "join" || myMembership === "invite";
+    });
+};
 
-    // build a per-device message for each user
-    var content = {};
-    for (var userId in roomsByUser) {
-        if (!roomsByUser.hasOwnProperty(userId)) {
-            continue;
+Crypto.prototype._onToDeviceEvent = function (event) {
+    try {
+        if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") {
+            this._onRoomKeyEvent(event);
+        } else if (event.getType() == "m.room_key_request") {
+            this._onRoomKeyRequestEvent(event);
+        } else if (event.getType() === "m.key.verification.request") {
+            this._onKeyVerificationRequest(event);
+        } else if (event.getType() === "m.key.verification.start") {
+            this._onKeyVerificationStart(event);
+        } else if (event.getContent().transaction_id) {
+            this._onKeyVerificationMessage(event);
+        } else if (event.getContent().msgtype === "m.bad.encrypted") {
+            this._onToDeviceBadEncrypted(event);
+        } else if (event.isBeingDecrypted()) {
+            // once the event has been decrypted, try again
+            event.once('Event.decrypted', ev => {
+                this._onToDeviceEvent(ev);
+            });
         }
-        content[userId] = {
-            "*": {
-                device_id: this._deviceId,
-                rooms: roomsByUser[userId],
-            },
-        };
+    } catch (e) {
+        _logger2.default.error("Error handling toDeviceEvent:", e);
     }
-
-    var self = this;
-    this._baseApis.sendToDevice(
-        "m.new_device", // OH HAI!
-        content
-    ).done(function() {
-        self._sessionStore.setDeviceAnnounced();
-    });
 };
 
 /**
  * Handle a key event
  *
  * @private
  * @param {module:models/event.MatrixEvent} event key event
  */
-Crypto.prototype._onRoomKeyEvent = function(event) {
-    var content = event.getContent();
+Crypto.prototype._onRoomKeyEvent = function (event) {
+    const content = event.getContent();
 
     if (!content.room_id || !content.algorithm) {
-        console.error("key event is missing fields");
+        _logger2.default.error("key event is missing fields");
+        return;
+    }
+
+    if (!this._checkedForBackup) {
+        // don't bother awaiting on this - the important thing is that we retry if we
+        // haven't managed to check before
+        this._checkAndStartKeyBackup();
+    }
+
+    const alg = this._getRoomDecryptor(content.room_id, content.algorithm);
+    alg.onRoomKeyEvent(event);
+};
+
+/**
+ * Handle a key verification request event.
+ *
+ * @private
+ * @param {module:models/event.MatrixEvent} event verification request event
+ */
+Crypto.prototype._onKeyVerificationRequest = function (event) {
+    if (event.isCancelled()) {
+        _logger2.default.warn("Ignoring flagged verification request from " + event.getSender());
+        return;
+    }
+
+    const content = event.getContent();
+    if (!("from_device" in content) || typeof content.from_device !== "string" || !("transaction_id" in content) || typeof content.from_device !== "string" || !("methods" in content) || !(content.methods instanceof Array) || !("timestamp" in content) || typeof content.timestamp !== "number") {
+        _logger2.default.warn("received invalid verification request from " + event.getSender());
+        // ignore event if malformed
+        return;
+    }
+
+    const now = Date.now();
+    if (now < content.timestamp - 5 * 60 * 1000 || now > content.timestamp + 10 * 60 * 1000) {
+        // ignore if event is too far in the past or too far in the future
+        _logger2.default.log("received verification that is too old or from the future");
+        return;
+    }
+
+    const sender = event.getSender();
+    if (sender === this._userId && content.from_device === this._deviceId) {
+        // ignore requests from ourselves, because it doesn't make sense for a
+        // device to verify itself
+        return;
+    }
+    if (this._verificationTransactions.has(sender)) {
+        if (this._verificationTransactions.get(sender).has(content.transaction_id)) {
+            // transaction already exists: cancel it and drop the existing
+            // request because someone has gotten confused
+            const err = (0, _Error.newUnexpectedMessageError)({
+                transaction_id: content.transaction_id
+            });
+            if (this._verificationTransactions.get(sender).get(content.transaction_id).verifier) {
+                this._verificationTransactions.get(sender).get(content.transaction_id).verifier.cancel(err);
+            } else {
+                this._verificationTransactions.get(sender).get(content.transaction_id).reject(err);
+                this.sendToDevice("m.key.verification.cancel", {
+                    [sender]: {
+                        [content.from_device]: err.getContent()
+                    }
+                });
+            }
+            this._verificationTransactions.get(sender).delete(content.transaction_id);
+            return;
+        }
+    } else {
+        this._verificationTransactions.set(sender, new Map());
+    }
+
+    // determine what requested methods we support
+    const methods = [];
+    for (const method of content.methods) {
+        if (typeof method !== "string") {
+            continue;
+        }
+        if (this._verificationMethods.has(method)) {
+            methods.push(method);
+        }
+    }
+    if (methods.length === 0) {
+        this._baseApis.emit("crypto.verification.request.unknown", event.getSender(), () => {
+            this.sendToDevice("m.key.verification.cancel", {
+                [sender]: {
+                    [content.from_device]: (0, _Error.newUserCancelledError)({
+                        transaction_id: content.transaction_id
+                    }).getContent()
+                }
+            });
+        });
+    } else {
+        // notify the application of the verification request, so it can
+        // decide what to do with it
+        const request = {
+            event: event,
+            methods: methods,
+            beginKeyVerification: method => {
+                const verifier = this.beginKeyVerification(method, sender, content.from_device, content.transaction_id);
+                this._verificationTransactions.get(sender).get(content.transaction_id).verifier = verifier;
+                return verifier;
+            },
+            cancel: () => {
+                this._baseApis.sendToDevice("m.key.verification.cancel", {
+                    [sender]: {
+                        [content.from_device]: (0, _Error.newUserCancelledError)({
+                            transaction_id: content.transaction_id
+                        }).getContent()
+                    }
+                });
+            }
+        };
+        this._verificationTransactions.get(sender).set(content.transaction_id, {
+            request: request
+        });
+        this._baseApis.emit("crypto.verification.request", request);
+    }
+};
+
+/**
+ * Handle a key verification start event.
+ *
+ * @private
+ * @param {module:models/event.MatrixEvent} event verification start event
+ */
+Crypto.prototype._onKeyVerificationStart = function (event) {
+    if (event.isCancelled()) {
+        _logger2.default.warn("Ignoring flagged verification start from " + event.getSender());
+        return;
+    }
+
+    const sender = event.getSender();
+    const content = event.getContent();
+    const transactionId = content.transaction_id;
+    const deviceId = content.from_device;
+    if (!transactionId || !deviceId) {
+        // invalid request, and we don't have enough information to send a
+        // cancellation, so just ignore it
         return;
     }
 
-    var alg = this._getRoomDecryptor(content.room_id, content.algorithm);
-    alg.onRoomKeyEvent(event);
+    let handler = this._verificationTransactions.has(sender) && this._verificationTransactions.get(sender).get(transactionId);
+    // if the verification start message is invalid, send a cancel message to
+    // the other side, and also send a cancellation event
+    const cancel = err => {
+        if (handler.verifier) {
+            handler.verifier.cancel(err);
+        } else if (handler.request && handler.request.cancel) {
+            handler.request.cancel(err);
+        }
+        this.sendToDevice("m.key.verification.cancel", {
+            [sender]: {
+                [deviceId]: err.getContent()
+            }
+        });
+    };
+    if (!this._verificationMethods.has(content.method)) {
+        cancel((0, _Error.newUnknownMethodError)({
+            transaction_id: content.transactionId
+        }));
+        return;
+    } else if (content.next_method) {
+        if (!this._verificationMethods.has(content.next_method)) {
+            cancel((0, _Error.newUnknownMethodError)({
+                transaction_id: content.transactionId
+            }));
+            return;
+        } else {
+            /* TODO:
+            const verification = new TwoPartVerification(
+                this._verificationMethods[content.method],
+                this._verificationMethods[content.next_method],
+                userId, deviceId,
+            );
+            this.emit(verification.event_type, verification);
+            this.emit(verification.first.event_type, verification);*/
+        }
+    } else {
+        const verifier = new (this._verificationMethods.get(content.method))(this._baseApis, sender, deviceId, content.transaction_id, event, handler && handler.request);
+        if (!handler) {
+            if (!this._verificationTransactions.has(sender)) {
+                this._verificationTransactions.set(sender, new Map());
+            }
+            handler = this._verificationTransactions.get(sender).set(transactionId, {
+                verifier: verifier
+            });
+        } else {
+            if (!handler.verifier) {
+                handler.verifier = verifier;
+                if (handler.request) {
+                    // the verification start was sent as a response to a
+                    // verification request
+
+                    if (!handler.request.devices.includes(deviceId)) {
+                        // didn't send a request to that device, so it
+                        // shouldn't have responded
+                        cancel((0, _Error.newUnexpectedMessageError)({
+                            transaction_id: content.transactionId
+                        }));
+                        return;
+                    }
+                    if (!handler.request.methods.includes(content.method)) {
+                        // verification method wasn't one that was requested
+                        cancel((0, _Error.newUnknownMethodError)({
+                            transaction_id: content.transactionId
+                        }));
+                        return;
+                    }
+
+                    // send cancellation messages to all the other devices that
+                    // the request was sent to
+                    const message = {
+                        transaction_id: transactionId,
+                        code: "m.accepted",
+                        reason: "Verification request accepted by another device"
+                    };
+                    const msgMap = {};
+                    for (const devId of handler.request.devices) {
+                        if (devId !== deviceId) {
+                            msgMap[devId] = message;
+                        }
+                    }
+                    this._baseApis.sendToDevice("m.key.verification.cancel", {
+                        [sender]: msgMap
+                    });
+
+                    handler.request.resolve(verifier);
+                }
+            } else {
+                // FIXME: make sure we're in a two-part verification, and the start matches the second part
+            }
+        }
+        this._baseApis.emit("crypto.verification.start", verifier);
+    }
+};
+
+/**
+ * Handle a general key verification event.
+ *
+ * @private
+ * @param {module:models/event.MatrixEvent} event verification start event
+ */
+Crypto.prototype._onKeyVerificationMessage = function (event) {
+    const sender = event.getSender();
+    const transactionId = event.getContent().transaction_id;
+    const handler = this._verificationTransactions.has(sender) && this._verificationTransactions.get(sender).get(transactionId);
+    if (!handler) {
+        return;
+    } else if (event.getType() === "m.key.verification.cancel") {
+        _logger2.default.log(event);
+        if (handler.verifier) {
+            handler.verifier.cancel(event);
+        } else if (handler.request && handler.request.cancel) {
+            handler.request.cancel(event);
+        }
+    } else if (handler.verifier) {
+        const verifier = handler.verifier;
+        if (verifier.events && verifier.events.includes(event.getType())) {
+            verifier.handleEvent(event);
+        }
+    }
+};
+
+/**
+ * Handle a toDevice event that couldn't be decrypted
+ *
+ * @private
+ * @param {module:models/event.MatrixEvent} event undecryptable event
+ */
+Crypto.prototype._onToDeviceBadEncrypted = async function (event) {
+    const content = event.getWireContent();
+    const sender = event.getSender();
+    const algorithm = content.algorithm;
+    const deviceKey = content.sender_key;
+
+    if (sender === undefined || deviceKey === undefined || deviceKey === undefined) {
+        return;
+    }
+
+    // check when we last forced a new session with this device: if we've already done so
+    // recently, don't do it again.
+    this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {};
+    const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0;
+    if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) {
+        _logger2.default.debug("New session already forced with device " + sender + ":" + deviceKey + " at " + lastNewSessionForced + ": not forcing another");
+        return;
+    }
+
+    // establish a new olm session with this device since we're failing to decrypt messages
+    // on a current session.
+    // Note that an undecryptable message from another device could easily be spoofed -
+    // is there anything we can do to mitigate this?
+    const device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey);
+    if (!device) {
+        _logger2.default.info("Couldn't find device for identity key " + deviceKey + ": not re-establishing session");
+        return;
+    }
+    const devicesByUser = {};
+    devicesByUser[sender] = [device];
+    await olmlib.ensureOlmSessionsForDevices(this._olmDevice, this._baseApis, devicesByUser, true);
+
+    this._lastNewSessionForced[sender][deviceKey] = Date.now();
+
+    // Now send a blank message on that session so the other side knows about it.
+    // (The keyshare request is sent in the clear so that won't do)
+    // We send this first such that, as long as the toDevice messages arrive in the
+    // same order we sent them, the other end will get this first, set up the new session,
+    // then get the keyshare request and send the key over this new session (because it
+    // is the session it has most recently received a message on).
+    const encryptedContent = {
+        algorithm: olmlib.OLM_ALGORITHM,
+        sender_key: this._olmDevice.deviceCurve25519Key,
+        ciphertext: {}
+    };
+    await olmlib.encryptMessageForDevice(encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, sender, device, { type: "m.dummy" });
+
+    await this._baseApis.sendToDevice("m.room.encrypted", {
+        [sender]: {
+            [device.deviceId]: encryptedContent
+        }
+    });
+
+    // Most of the time this probably won't be necessary since we'll have queued up a key request when
+    // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending
+    // it. This won't always be the case though so we need to re-send any that have already been sent
+    // to avoid races.
+    const requestsToResend = await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(sender, device.deviceId);
+    for (const keyReq of requestsToResend) {
+        this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true);
+    }
 };
 
 /**
  * Handle a change in the membership state of a member of a room
  *
  * @private
  * @param {module:models/event.MatrixEvent} event  event causing the change
  * @param {module:models/room-member} member  user whose membership changed
  * @param {string=} oldMembership  previous membership
  */
-Crypto.prototype._onRoomMembership = function(event, member, oldMembership) {
-
+Crypto.prototype._onRoomMembership = function (event, member, oldMembership) {
     // this event handler is registered on the *client* (as opposed to the room
     // member itself), which means it is only called on changes to the *live*
     // membership state (ie, it is not called when we back-paginate, nor when
     // we load the state in the initialsync).
     //
     // Further, it is automatically registered and called when new members
     // arrive in the room.
 
-    var roomId = member.roomId;
+    const roomId = member.roomId;
 
-    var alg = this._roomEncryptors[roomId];
+    const alg = this._roomEncryptors[roomId];
     if (!alg) {
         // not encrypting in this room
         return;
     }
+    // only mark users in this room as tracked if we already started tracking in this room
+    // this way we don't start device queries after sync on behalf of this room which we won't use
+    // the result of anyway, as we'll need to do a query again once all the members are fetched
+    // by calling _trackRoomDevices
+    if (this._roomDeviceTrackingState[roomId]) {
+        if (member.membership == 'join') {
+            _logger2.default.log('Join event for ' + member.userId + ' in ' + roomId);
+            // make sure we are tracking the deviceList for this user
+            this._deviceList.startTrackingDeviceList(member.userId);
+        } else if (member.membership == 'invite' && this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) {
+            _logger2.default.log('Invite event for ' + member.userId + ' in ' + roomId);
+            this._deviceList.startTrackingDeviceList(member.userId);
+        }
+    }
 
     alg.onRoomMembership(event, member, oldMembership);
 };
 
-
 /**
- * Called when a new device announces itself
+ * Called when we get an m.room_key_request event.
  *
  * @private
- * @param {module:models/event.MatrixEvent} event announcement event
+ * @param {module:models/event.MatrixEvent} event key request event
  */
-Crypto.prototype._onNewDeviceEvent = function(event) {
-    var content = event.getContent();
-    var userId = event.getSender();
-    var deviceId = content.device_id;
-    var rooms = content.rooms;
+Crypto.prototype._onRoomKeyRequestEvent = function (event) {
+    const content = event.getContent();
+    if (content.action === "request") {
+        // Queue it up for now, because they tend to arrive before the room state
+        // events at initial sync, and we want to see if we know anything about the
+        // room before passing them on to the app.
+        const req = new IncomingRoomKeyRequest(event);
+        this._receivedRoomKeyRequests.push(req);
+    } else if (content.action === "request_cancellation") {
+        const req = new IncomingRoomKeyRequestCancellation(event);
+        this._receivedRoomKeyRequestCancellations.push(req);
+    }
+};
 
-    if (!rooms || !deviceId) {
-        console.warn("new_device event missing keys");
+/**
+ * Process any m.room_key_request events which were queued up during the
+ * current sync.
+ *
+ * @private
+ */
+Crypto.prototype._processReceivedRoomKeyRequests = async function () {
+    if (this._processingRoomKeyRequests) {
+        // we're still processing last time's requests; keep queuing new ones
+        // up for now.
         return;
     }
+    this._processingRoomKeyRequests = true;
 
-    console.log("m.new_device event from " + userId + ":" + deviceId +
-                " for rooms " + rooms);
+    try {
+        // we need to grab and clear the queues in the synchronous bit of this method,
+        // so that we don't end up racing with the next /sync.
+        const requests = this._receivedRoomKeyRequests;
+        this._receivedRoomKeyRequests = [];
+        const cancellations = this._receivedRoomKeyRequestCancellations;
+        this._receivedRoomKeyRequestCancellations = [];
 
-    if (this.getStoredDevice(userId, deviceId)) {
-        console.log("Known device; ignoring");
-        return;
-    }
-
-    this._pendingNewDevices[userId] = this._pendingNewDevices[userId] || {};
-    this._pendingNewDevices[userId][deviceId] = true;
-
-    // we delay handling these until the intialsync has completed, so that we
-    // can do all of them together.
-    if (this._initialSyncCompleted) {
-        this._flushNewDeviceRequests();
+        // Process all of the requests, *then* all of the cancellations.
+        //
+        // This makes sure that if we get a request and its cancellation in the
+        // same /sync result, then we process the request before the
+        // cancellation (and end up with a cancelled request), rather than the
+        // cancellation before the request (and end up with an outstanding
+        // request which should have been cancelled.)
+        await _bluebird2.default.map(requests, req => this._processReceivedRoomKeyRequest(req));
+        await _bluebird2.default.map(cancellations, cancellation => this._processReceivedRoomKeyRequestCancellation(cancellation));
+    } catch (e) {
+        _logger2.default.error(`Error processing room key requsts: ${e}`);
+    } finally {
+        this._processingRoomKeyRequests = false;
     }
 };
 
 /**
- * Start device queries for any users who sent us an m.new_device recently
+ * Helper for processReceivedRoomKeyRequests
+ *
+ * @param {IncomingRoomKeyRequest} req
  */
-Crypto.prototype._flushNewDeviceRequests = function() {
-    var self = this;
+Crypto.prototype._processReceivedRoomKeyRequest = async function (req) {
+    const userId = req.userId;
+    const deviceId = req.deviceId;
+
+    const body = req.requestBody;
+    const roomId = body.room_id;
+    const alg = body.algorithm;
+
+    _logger2.default.log(`m.room_key_request from ${userId}:${deviceId}` + ` for ${roomId} / ${body.session_id} (id ${req.requestId})`);
 
-    var pending = this._pendingNewDevices;
-    var users = utils.keys(pending).filter(function(u) {
-        return utils.keys(pending[u]).length > 0;
-    });
+    if (userId !== this._userId) {
+        if (!this._roomEncryptors[roomId]) {
+            _logger2.default.debug(`room key request for unencrypted room ${roomId}`);
+            return;
+        }
+        const encryptor = this._roomEncryptors[roomId];
+        const device = this._deviceList.getStoredDevice(userId, deviceId);
+        if (!device) {
+            _logger2.default.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`);
+            return;
+        }
 
-    if (users.length === 0) {
+        try {
+            await encryptor.reshareKeyWithDevice(body.sender_key, body.session_id, userId, device);
+        } catch (e) {
+            _logger2.default.warn("Failed to re-share keys for session " + body.session_id + " with device " + userId + ":" + device.deviceId, e);
+        }
         return;
     }
 
-    var r = this._doKeyDownloadForUsers(users);
+    // todo: should we queue up requests we don't yet have keys for,
+    // in case they turn up later?
+
+    // if we don't have a decryptor for this room/alg, we don't have
+    // the keys for the requested events, and can drop the requests.
+    if (!this._roomDecryptors[roomId]) {
+        _logger2.default.log(`room key request for unencrypted room ${roomId}`);
+        return;
+    }
 
-    // we've kicked off requests to these users: remove their
-    // pending flag for now.
-    this._pendingNewDevices = {};
+    const decryptor = this._roomDecryptors[roomId][alg];
+    if (!decryptor) {
+        _logger2.default.log(`room key request for unknown alg ${alg} in room ${roomId}`);
+        return;
+    }
+
+    if (!(await decryptor.hasKeysForKeyRequest(req))) {
+        _logger2.default.log(`room key request for unknown session ${roomId} / ` + body.session_id);
+        return;
+    }
 
-    users.map(function(u) {
-        r[u] = r[u].catch(function(e) {
-            console.error(
-                'Error updating device keys for user ' + u + ':', e
-            );
+    req.share = () => {
+        decryptor.shareKeysWithDevice(req);
+    };
+
+    // if the device is is verified already, share the keys
+    const device = this._deviceList.getStoredDevice(userId, deviceId);
+    if (device && device.isVerified()) {
+        _logger2.default.log('device is already verified: sharing keys');
+        req.share();
+        return;
+    }
 
-            // reinstate the pending flags on any users which failed; this will
-            // mean that we will do another download in the future, but won't
-            // tight-loop.
-            //
-            self._pendingNewDevices[u] = self._pendingNewDevices[u] || {};
-            utils.update(self._pendingNewDevices[u], pending[u]);
-        });
-    });
+    this.emit("crypto.roomKeyRequest", req);
+};
 
-    q.all(utils.values(r)).done();
+/**
+ * Helper for processReceivedRoomKeyRequests
+ *
+ * @param {IncomingRoomKeyRequestCancellation} cancellation
+ */
+Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function (cancellation) {
+    _logger2.default.log(`m.room_key_request cancellation for ${cancellation.userId}:` + `${cancellation.deviceId} (id ${cancellation.requestId})`);
+
+    // we should probably only notify the app of cancellations we told it
+    // about, but we don't currently have a record of that, so we just pass
+    // everything through.
+    this.emit("crypto.roomKeyRequestCancellation", cancellation);
 };
 
 /**
  * Get a decryptor for a given room and algorithm.
  *
  * If we already have a decryptor for the given room and algorithm, return
  * it. Otherwise try to instantiate it.
  *
@@ -1196,64 +2080,147 @@ Crypto.prototype._flushNewDeviceRequests
  *
  * @param {string} algorithm  crypto algorithm
  *
  * @return {module:crypto.algorithms.base.DecryptionAlgorithm}
  *
  * @raises {module:crypto.algorithms.DecryptionError} if the algorithm is
  * unknown
  */
-Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) {
-    var decryptors;
-    var alg;
+Crypto.prototype._getRoomDecryptor = function (roomId, algorithm) {
+    let decryptors;
+    let alg;
 
     roomId = roomId || null;
     if (roomId) {
         decryptors = this._roomDecryptors[roomId];
         if (!decryptors) {
             this._roomDecryptors[roomId] = decryptors = {};
         }
 
         alg = decryptors[algorithm];
         if (alg) {
             return alg;
         }
     }
 
-    var AlgClass = algorithms.DECRYPTION_CLASSES[algorithm];
+    const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm];
     if (!AlgClass) {
-        throw new algorithms.DecryptionError("Unable to decrypt " + algorithm);
+        throw new algorithms.DecryptionError('UNKNOWN_ENCRYPTION_ALGORITHM', 'Unknown encryption algorithm "' + algorithm + '".');
     }
     alg = new AlgClass({
         userId: this._userId,
         crypto: this,
         olmDevice: this._olmDevice,
-        roomId: roomId,
+        baseApis: this._baseApis,
+        roomId: roomId
     });
 
     if (decryptors) {
         decryptors[algorithm] = alg;
     }
     return alg;
 };
 
-
 /**
  * sign the given object with our ed25519 key
  *
  * @param {Object} obj  Object to which we will add a 'signatures' property
  */
-Crypto.prototype._signObject = function(obj) {
-    var sigs = {};
+Crypto.prototype._signObject = async function (obj) {
+    const sigs = {};
     sigs[this._userId] = {};
-    sigs[this._userId]["ed25519:" + this._deviceId] =
-        this._olmDevice.sign(anotherjson.stringify(obj));
+    sigs[this._userId]["ed25519:" + this._deviceId] = await this._olmDevice.sign(anotherjson.stringify(obj));
     obj.signatures = sigs;
 };
 
 /**
- * @see module:crypto/algorithms/base.DecryptionError
+ * The parameters of a room key request. The details of the request may
+ * vary with the crypto algorithm, but the management and storage layers for
+ * outgoing requests expect it to have 'room_id' and 'session_id' properties.
+ *
+ * @typedef {Object} RoomKeyRequestBody
+ */
+
+/**
+ * Represents a received m.room_key_request event
+ *
+ * @property {string} userId    user requesting the key
+ * @property {string} deviceId  device requesting the key
+ * @property {string} requestId unique id for the request
+ * @property {module:crypto~RoomKeyRequestBody} requestBody
+ * @property {function()} share  callback which, when called, will ask
+ *    the relevant crypto algorithm implementation to share the keys for
+ *    this request.
  */
-Crypto.DecryptionError = algorithms.DecryptionError;
+class IncomingRoomKeyRequest {
+    constructor(event) {
+        const content = event.getContent();
+
+        this.userId = event.getSender();
+        this.deviceId = content.requesting_device_id;
+        this.requestId = content.request_id;
+        this.requestBody = content.body || {};
+        this.share = () => {
+            throw new Error("don't know how to share keys for this request yet");
+        };
+    }
+}
+
+/**
+ * Represents a received m.room_key_request cancellation
+ *
+ * @property {string} userId    user requesting the cancellation
+ * @property {string} deviceId  device requesting the cancellation
+ * @property {string} requestId unique id for the request to be cancelled
+ */
+class IncomingRoomKeyRequestCancellation {
+    constructor(event) {
+        const content = event.getContent();
 
+        this.userId = event.getSender();
+        this.deviceId = content.requesting_device_id;
+        this.requestId = content.request_id;
+    }
+}
 
-/** */
-module.exports = Crypto;
+/**
+ * The result of a (successful) call to decryptEvent.
+ *
+ * @typedef {Object} EventDecryptionResult
+ *
+ * @property {Object} clearEvent The plaintext payload for the event
+ *     (typically containing <tt>type</tt> and <tt>content</tt> fields).
+ *
+ * @property {?string} senderCurve25519Key Key owned by the sender of this
+ *    event.  See {@link module:models/event.MatrixEvent#getSenderKey}.
+ *
+ * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of
+ *    this event. See
+ *    {@link module:models/event.MatrixEvent#getClaimedEd25519Key}.
+ *
+ * @property {?Array<string>} forwardingCurve25519KeyChain list of curve25519
+ *     keys involved in telling us about the senderCurve25519Key and
+ *     claimedEd25519Key. See
+ *     {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}.
+ */
+
+/**
+ * Fires when we receive a room key request
+ *
+ * @event module:client~MatrixClient#"crypto.roomKeyRequest"
+ * @param {module:crypto~IncomingRoomKeyRequest} req  request details
+ */
+
+/**
+ * Fires when we receive a room key request cancellation
+ *
+ * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation"
+ * @param {module:crypto~IncomingRoomKeyRequestCancellation} req
+ */
+
+/**
+ * Fires when the app may wish to warn the user about something related
+ * the end-to-end crypto.
+ *
+ * @event module:client~MatrixClient#"crypto.warning"
+ * @param {string} type One of the strings listed above
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/olmlib.js
@@ -1,268 +1,308 @@
-/*
-Copyright 2016 OpenMarket Ltd
+'use strict';
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+var _logger = require('../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+const anotherjson = require('another-json'); /*
+                                             Copyright 2016 OpenMarket Ltd
+                                             Copyright 2019 New Vector Ltd
+                                             
+                                             Licensed under the Apache License, Version 2.0 (the "License");
+                                             you may not use this file except in compliance with the License.
+                                             You may obtain a copy of the License at
+                                             
+                                                 http://www.apache.org/licenses/LICENSE-2.0
+                                             
+                                             Unless required by applicable law or agreed to in writing, software
+                                             distributed under the License is distributed on an "AS IS" BASIS,
+                                             WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                                             See the License for the specific language governing permissions and
+                                             limitations under the License.
+                                             */
 
 /**
  * @module olmlib
  *
  * Utilities common to olm encryption algorithms
  */
 
-var q = require('q');
-var anotherjson = require('another-json');
-
-var utils = require("../utils");
+const utils = require("../utils");
 
 /**
  * matrix algorithm tag for olm
  */
 module.exports.OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
 
 /**
  * matrix algorithm tag for megolm
  */
 module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
 
+/**
+ * matrix algorithm tag for megolm backups
+ */
+module.exports.MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2";
 
 /**
  * Encrypt an event payload for an Olm device
  *
  * @param {Object<string, string>} resultsObject  The `ciphertext` property
  *   of the m.room.encrypted event to which to add our result
  *
  * @param {string} ourUserId
  * @param {string} ourDeviceId
  * @param {module:crypto/OlmDevice} olmDevice olm.js wrapper
  * @param {string} recipientUserId
  * @param {module:crypto/deviceinfo} recipientDevice
  * @param {object} payloadFields fields to include in the encrypted payload
+ *
+ * Returns a promise which resolves (to undefined) when the payload
+ *    has been encrypted into `resultsObject`
  */
-module.exports.encryptMessageForDevice = function(
-    resultsObject,
-    ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice,
-    payloadFields
-) {
-    var deviceKey = recipientDevice.getIdentityKey();
-    var sessionId = olmDevice.getSessionIdForDevice(deviceKey);
+module.exports.encryptMessageForDevice = async function (resultsObject, ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, payloadFields) {
+    const deviceKey = recipientDevice.getIdentityKey();
+    const sessionId = await olmDevice.getSessionIdForDevice(deviceKey);
     if (sessionId === null) {
         // If we don't have a session for a device then
         // we can't encrypt a message for it.
         return;
     }
 
-    console.log(
-        "Using sessionid " + sessionId + " for device " +
-            recipientUserId + ":" + recipientDevice.deviceId
-    );
+    _logger2.default.log("Using sessionid " + sessionId + " for device " + recipientUserId + ":" + recipientDevice.deviceId);
 
-    var payload = {
+    const payload = {
         sender: ourUserId,
         sender_device: ourDeviceId,
 
         // Include the Ed25519 key so that the recipient knows what
         // device this message came from.
         // We don't need to include the curve25519 key since the
         // recipient will already know this from the olm headers.
         // When combined with the device keys retrieved from the
         // homeserver signed by the ed25519 key this proves that
         // the curve25519 key and the ed25519 key are owned by
         // the same device.
         keys: {
-            "ed25519": olmDevice.deviceEd25519Key,
+            "ed25519": olmDevice.deviceEd25519Key
         },
 
         // include the recipient device details in the payload,
         // to avoid unknown key attacks, per
         // https://github.com/vector-im/vector-web/issues/2483
         recipient: recipientUserId,
         recipient_keys: {
-            "ed25519": recipientDevice.getFingerprint(),
-        },
+            "ed25519": recipientDevice.getFingerprint()
+        }
     };
 
     // TODO: technically, a bunch of that stuff only needs to be included for
     // pre-key messages: after that, both sides know exactly which devices are
     // involved in the session. If we're looking to reduce data transfer in the
     // future, we could elide them for subsequent messages.
 
     utils.extend(payload, payloadFields);
 
-    resultsObject[deviceKey] = olmDevice.encryptMessage(
-        deviceKey, sessionId, JSON.stringify(payload)
-    );
+    resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload));
 };
 
 /**
  * Try to make sure we have established olm sessions for the given devices.
  *
  * @param {module:crypto/OlmDevice} olmDevice
  *
  * @param {module:base-apis~MatrixBaseApis} baseApis
  *
  * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
- *    map from userid to list of devices
+ *    map from userid to list of devices to ensure sessions for
+ *
+ * @param {bolean} force If true, establish a new session even if one already exists.
+ *     Optional.
  *
  * @return {module:client.Promise} resolves once the sessions are complete, to
  *    an Object mapping from userId to deviceId to
  *    {@link module:crypto~OlmSessionResult}
  */
-module.exports.ensureOlmSessionsForDevices = function(
-    olmDevice, baseApis, devicesByUser
-) {
-    var devicesWithoutSession = [
+module.exports.ensureOlmSessionsForDevices = async function (olmDevice, baseApis, devicesByUser, force) {
+    const devicesWithoutSession = [
         // [userId, deviceId], ...
     ];
-    var result = {};
+    const result = {};
+    const resolveSession = {};
 
-    for (var userId in devicesByUser) {
-        if (!devicesByUser.hasOwnProperty(userId)) { continue; }
+    for (const userId in devicesByUser) {
+        if (!devicesByUser.hasOwnProperty(userId)) {
+            continue;
+        }
         result[userId] = {};
-        var devices = devicesByUser[userId];
-        for (var j = 0; j < devices.length; j++) {
-            var deviceInfo = devices[j];
-            var deviceId = deviceInfo.deviceId;
-            var key = deviceInfo.getIdentityKey();
-            var sessionId = olmDevice.getSessionIdForDevice(key);
-            if (sessionId === null) {
+        const devices = devicesByUser[userId];
+        for (let j = 0; j < devices.length; j++) {
+            const deviceInfo = devices[j];
+            const deviceId = deviceInfo.deviceId;
+            const key = deviceInfo.getIdentityKey();
+            if (!olmDevice._sessionsInProgress[key]) {
+                // pre-emptively mark the session as in-progress to avoid race
+                // conditions.  If we find that we already have a session, then
+                // we'll resolve
+                olmDevice._sessionsInProgress[key] = new _bluebird2.default((resolve, reject) => {
+                    resolveSession[key] = {
+                        resolve: (...args) => {
+                            delete olmDevice._sessionsInProgress[key];
+                            resolve(...args);
+                        },
+                        reject: (...args) => {
+                            delete olmDevice._sessionsInProgress[key];
+                            reject(...args);
+                        }
+                    };
+                });
+            }
+            const sessionId = await olmDevice.getSessionIdForDevice(key, resolveSession[key]);
+            if (sessionId !== null && resolveSession[key]) {
+                // we found a session, but we had marked the session as
+                // in-progress, so unmark it and unblock anything that was
+                // waiting
+                delete olmDevice._sessionsInProgress[key];
+                resolveSession[key].resolve();
+                delete resolveSession[key];
+            }
+            if (sessionId === null || force) {
                 devicesWithoutSession.push([userId, deviceId]);
             }
             result[userId][deviceId] = {
                 device: deviceInfo,
-                sessionId: sessionId,
+                sessionId: sessionId
             };
         }
     }
 
     if (devicesWithoutSession.length === 0) {
-        return q(result);
+        return result;
+    }
+
+    const oneTimeKeyAlgorithm = "signed_curve25519";
+    let res;
+    try {
+        res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm);
+    } catch (e) {
+        for (const resolver of Object.values(resolveSession)) {
+            resolver.resolve();
+        }
+        _logger2.default.log("failed to claim one-time keys", e, devicesWithoutSession);
+        throw e;
     }
 
-    // TODO: this has a race condition - if we try to send another message
-    // while we are claiming a key, we will end up claiming two and setting up
-    // two sessions.
-    //
-    // That should eventually resolve itself, but it's poor form.
+    const otk_res = res.one_time_keys || {};
+    const promises = [];
+    for (const userId in devicesByUser) {
+        if (!devicesByUser.hasOwnProperty(userId)) {
+            continue;
+        }
+        const userRes = otk_res[userId] || {};
+        const devices = devicesByUser[userId];
+        for (let j = 0; j < devices.length; j++) {
+            const deviceInfo = devices[j];
+            const deviceId = deviceInfo.deviceId;
+            const key = deviceInfo.getIdentityKey();
+            if (result[userId][deviceId].sessionId && !force) {
+                // we already have a result for this device
+                continue;
+            }
 
-    var oneTimeKeyAlgorithm = "signed_curve25519";
-    return baseApis.claimOneTimeKeys(
-        devicesWithoutSession, oneTimeKeyAlgorithm
-    ).then(function(res) {
-        for (var userId in devicesByUser) {
-            if (!devicesByUser.hasOwnProperty(userId)) { continue; }
-            var userRes = res.one_time_keys[userId] || {};
-            var devices = devicesByUser[userId];
-            for (var j = 0; j < devices.length; j++) {
-                var deviceInfo = devices[j];
-                var deviceId = deviceInfo.deviceId;
-                if (result[userId][deviceId].sessionId) {
-                    // we already have a result for this device
-                    continue;
+            const deviceRes = userRes[deviceId] || {};
+            let oneTimeKey = null;
+            for (const keyId in deviceRes) {
+                if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) {
+                    oneTimeKey = deviceRes[keyId];
                 }
+            }
 
-                var deviceRes = userRes[deviceId] || {};
-                var oneTimeKey = null;
-                for (var keyId in deviceRes) {
-                    if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) {
-                        oneTimeKey = deviceRes[keyId];
-                    }
+            if (!oneTimeKey) {
+                const msg = "No one-time keys (alg=" + oneTimeKeyAlgorithm + ") for device " + userId + ":" + deviceId;
+                _logger2.default.warn(msg);
+                if (resolveSession[key]) {
+                    resolveSession[key].resolve();
                 }
+                continue;
+            }
 
-                if (!oneTimeKey) {
-                    console.warn(
-                        "No one-time keys (alg=" + oneTimeKeyAlgorithm +
-                            ") for device " + userId + ":" + deviceId
-                    );
-                    continue;
+            promises.push(_verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then(sid => {
+                if (resolveSession[key]) {
+                    resolveSession[key].resolve(sid);
                 }
+                result[userId][deviceId].sessionId = sid;
+            }, e => {
+                if (resolveSession[key]) {
+                    resolveSession[key].resolve();
+                }
+                throw e;
+            }));
+        }
+    }
 
-                var sid = _verifyKeyAndStartSession(
-                    olmDevice, oneTimeKey, userId, deviceInfo
-                );
-                result[userId][deviceId].sessionId = sid;
-            }
-        }
-        return result;
-    });
+    await _bluebird2.default.all(promises);
+    return result;
 };
 
-
-function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) {
-    var deviceId = deviceInfo.deviceId;
+async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) {
+    const deviceId = deviceInfo.deviceId;
     try {
-        _verifySignature(
-            olmDevice, oneTimeKey, userId, deviceId,
-            deviceInfo.getFingerprint()
-        );
+        await _verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint());
     } catch (e) {
-        console.error(
-            "Unable to verify signature on one-time key for device " +
-                userId + ":" + deviceId + ":", e
-        );
+        _logger2.default.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e);
         return null;
     }
 
-    var sid;
+    let sid;
     try {
-        sid = olmDevice.createOutboundSession(
-            deviceInfo.getIdentityKey(), oneTimeKey.key
-        );
+        sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key);
     } catch (e) {
         // possibly a bad key
-        console.error("Error starting session with device " +
-                      userId + ":" + deviceId + ": " + e);
+        _logger2.default.error("Error starting session with device " + userId + ":" + deviceId + ": " + e);
         return null;
     }
 
-    console.log("Started new sessionid " + sid +
-                " for device " + userId + ":" + deviceId);
+    _logger2.default.log("Started new sessionid " + sid + " for device " + userId + ":" + deviceId);
     return sid;
 }
 
-
 /**
  * Verify the signature on an object
  *
  * @param {module:crypto/OlmDevice} olmDevice olm wrapper to use for verify op
  *
  * @param {Object} obj object to check signature on. Note that this will be
  * stripped of its 'signatures' and 'unsigned' properties.
  *
  * @param {string} signingUserId  ID of the user whose signature should be checked
  *
  * @param {string} signingDeviceId  ID of the device whose signature should be checked
  *
  * @param {string} signingKey   base64-ed ed25519 public key
+ *
+ * Returns a promise which resolves (to undefined) if the the signature is good,
+ * or rejects with an Error if it is bad.
  */
-var _verifySignature = module.exports.verifySignature = function(
-    olmDevice, obj, signingUserId, signingDeviceId, signingKey
-) {
-    var signKeyId = "ed25519:" + signingDeviceId;
-    var signatures = obj.signatures || {};
-    var userSigs = signatures[signingUserId] || {};
-    var signature = userSigs[signKeyId];
+const _verifySignature = module.exports.verifySignature = async function (olmDevice, obj, signingUserId, signingDeviceId, signingKey) {
+    const signKeyId = "ed25519:" + signingDeviceId;
+    const signatures = obj.signatures || {};
+    const userSigs = signatures[signingUserId] || {};
+    const signature = userSigs[signKeyId];
     if (!signature) {
         throw Error("No signature");
     }
 
     // prepare the canonical json: remove unsigned and signatures, and stringify with
     // anotherjson
     delete obj.unsigned;
     delete obj.signatures;
-    var json = anotherjson.stringify(obj);
+    const json = anotherjson.stringify(obj);
 
-    olmDevice.verifySignature(
-        signingKey, json, signature
-    );
-};
+    olmDevice.verifySignature(signingKey, json, signature);
+};
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/recoverykey.js
@@ -0,0 +1,70 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.encodeRecoveryKey = encodeRecoveryKey;
+exports.decodeRecoveryKey = decodeRecoveryKey;
+
+var _bs = require('bs58');
+
+var _bs2 = _interopRequireDefault(_bs);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+// picked arbitrarily but to try & avoid clashing with any bitcoin ones
+// (which are also base58 encoded, but bitcoin's involve a lot more hashing)
+const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; /*
+                                              Copyright 2018 New Vector Ltd
+                                              
+                                              Licensed under the Apache License, Version 2.0 (the "License");
+                                              you may not use this file except in compliance with the License.
+                                              You may obtain a copy of the License at
+                                              
+                                                  http://www.apache.org/licenses/LICENSE-2.0
+                                              
+                                              Unless required by applicable law or agreed to in writing, software
+                                              distributed under the License is distributed on an "AS IS" BASIS,
+                                              WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                                              See the License for the specific language governing permissions and
+                                              limitations under the License.
+                                              */
+
+function encodeRecoveryKey(key) {
+    const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
+    buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
+    buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);
+
+    let parity = 0;
+    for (let i = 0; i < buf.length - 1; ++i) {
+        parity ^= buf[i];
+    }
+    buf[buf.length - 1] = parity;
+    const base58key = _bs2.default.encode(buf);
+
+    return base58key.match(/.{1,4}/g).join(" ");
+}
+
+function decodeRecoveryKey(recoverykey) {
+    const result = _bs2.default.decode(recoverykey.replace(/ /g, ''));
+
+    let parity = 0;
+    for (const b of result) {
+        parity ^= b;
+    }
+    if (parity !== 0) {
+        throw new Error("Incorrect parity");
+    }
+
+    for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
+        if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
+            throw new Error("Incorrect prefix");
+        }
+    }
+
+    if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1) {
+        throw new Error("Incorrect length");
+    }
+
+    return result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH);
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/store/base.js
@@ -0,0 +1,35 @@
+/**
+ * Internal module. Defintions for storage for the crypto module
+ *
+ * @module
+ */
+
+/**
+ * Abstraction of things that can store data required for end-to-end encryption
+ *
+ * @interface CryptoStore
+ */
+
+/**
+ * Represents an outgoing room key request
+ *
+ * @typedef {Object} OutgoingRoomKeyRequest
+ *
+ * @property {string} requestId    unique id for this request. Used for both
+ *    an id within the request for later pairing with a cancellation, and for
+ *    the transaction id when sending the to_device messages to our local
+ *    server.
+ *
+ * @property {string?} cancellationTxnId
+ *    transaction id for the cancellation, if any
+ *
+ * @property {Array<{userId: string, deviceId: string}>} recipients
+ *    list of recipients for the request
+ *
+ * @property {module:crypto~RoomKeyRequestBody} requestBody
+ *    parameters for the request.
+ *
+ * @property {Number} state   current state of this request (states are defined
+ *    in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES})
+ */
+"use strict";
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js
@@ -0,0 +1,699 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.Backend = exports.VERSION = undefined;
+exports.upgradeDatabase = upgradeDatabase;
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _utils = require('../../utils');
+
+var _utils2 = _interopRequireDefault(_utils);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const VERSION = exports.VERSION = 7;
+
+/**
+ * Implementation of a CryptoStore which is backed by an existing
+ * IndexedDB connection. Generally you want IndexedDBCryptoStore
+ * which connects to the database and defers to one of these.
+ *
+ * @implements {module:crypto/store/base~CryptoStore}
+ */
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+class Backend {
+    /**
+     * @param {IDBDatabase} db
+     */
+    constructor(db) {
+        this._db = db;
+
+        // make sure we close the db on `onversionchange` - otherwise
+        // attempts to delete the database will block (and subsequent
+        // attempts to re-create it will also block).
+        db.onversionchange = ev => {
+            _logger2.default.log(`versionchange for indexeddb ${this._dbName}: closing`);
+            db.close();
+        };
+    }
+
+    /**
+     * Look for an existing outgoing room key request, and if none is found,
+     * add a new one
+     *
+     * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request
+     *
+     * @returns {Promise} resolves to
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the
+     *    same instance as passed in, or the existing one.
+     */
+    getOrAddOutgoingRoomKeyRequest(request) {
+        const requestBody = request.requestBody;
+
+        const deferred = _bluebird2.default.defer();
+        const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite");
+        txn.onerror = deferred.reject;
+
+        // first see if we already have an entry for this request.
+        this._getOutgoingRoomKeyRequest(txn, requestBody, existing => {
+            if (existing) {
+                // this entry matches the request - return it.
+                _logger2.default.log(`already have key request outstanding for ` + `${requestBody.room_id} / ${requestBody.session_id}: ` + `not sending another`);
+                deferred.resolve(existing);
+                return;
+            }
+
+            // we got to the end of the list without finding a match
+            // - add the new request.
+            _logger2.default.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
+            txn.oncomplete = () => {
+                deferred.resolve(request);
+            };
+            const store = txn.objectStore("outgoingRoomKeyRequests");
+            store.add(request);
+        });
+
+        return deferred.promise;
+    }
+
+    /**
+     * Look for an existing room key request
+     *
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     *    existing request to look for
+     *
+     * @return {Promise} resolves to the matching
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    not found
+     */
+    getOutgoingRoomKeyRequest(requestBody) {
+        const deferred = _bluebird2.default.defer();
+
+        const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly");
+        txn.onerror = deferred.reject;
+
+        this._getOutgoingRoomKeyRequest(txn, requestBody, existing => {
+            deferred.resolve(existing);
+        });
+        return deferred.promise;
+    }
+
+    /**
+     * look for an existing room key request in the db
+     *
+     * @private
+     * @param {IDBTransaction} txn  database transaction
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     *    existing request to look for
+     * @param {Function} callback  function to call with the results of the
+     *    search. Either passed a matching
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    not found.
+     */
+    _getOutgoingRoomKeyRequest(txn, requestBody, callback) {
+        const store = txn.objectStore("outgoingRoomKeyRequests");
+
+        const idx = store.index("session");
+        const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]);
+
+        cursorReq.onsuccess = ev => {
+            const cursor = ev.target.result;
+            if (!cursor) {
+                // no match found
+                callback(null);
+                return;
+            }
+
+            const existing = cursor.value;
+
+            if (_utils2.default.deepCompare(existing.requestBody, requestBody)) {
+                // got a match
+                callback(existing);
+                return;
+            }
+
+            // look at the next entry in the index
+            cursor.continue();
+        };
+    }
+
+    /**
+     * Look for room key requests by state
+     *
+     * @param {Array<Number>} wantedStates list of acceptable states
+     *
+     * @return {Promise} resolves to the a
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    there are no pending requests in those states. If there are multiple
+     *    requests in those states, an arbitrary one is chosen.
+     */
+    getOutgoingRoomKeyRequestByState(wantedStates) {
+        if (wantedStates.length === 0) {
+            return _bluebird2.default.resolve(null);
+        }
+
+        // this is a bit tortuous because we need to make sure we do the lookup
+        // in a single transaction, to avoid having a race with the insertion
+        // code.
+
+        // index into the wantedStates array
+        let stateIndex = 0;
+        let result;
+
+        function onsuccess(ev) {
+            const cursor = ev.target.result;
+            if (cursor) {
+                // got a match
+                result = cursor.value;
+                return;
+            }
+
+            // try the next state in the list
+            stateIndex++;
+            if (stateIndex >= wantedStates.length) {
+                // no matches
+                return;
+            }
+
+            const wantedState = wantedStates[stateIndex];
+            const cursorReq = ev.target.source.openCursor(wantedState);
+            cursorReq.onsuccess = onsuccess;
+        }
+
+        const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly");
+        const store = txn.objectStore("outgoingRoomKeyRequests");
+
+        const wantedState = wantedStates[stateIndex];
+        const cursorReq = store.index("state").openCursor(wantedState);
+        cursorReq.onsuccess = onsuccess;
+
+        return promiseifyTxn(txn).then(() => result);
+    }
+
+    getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
+        let stateIndex = 0;
+        const results = [];
+
+        function onsuccess(ev) {
+            const cursor = ev.target.result;
+            if (cursor) {
+                const keyReq = cursor.value;
+                if (keyReq.recipients.includes({ userId, deviceId })) {
+                    results.push(keyReq);
+                }
+                cursor.continue();
+            } else {
+                // try the next state in the list
+                stateIndex++;
+                if (stateIndex >= wantedStates.length) {
+                    // no matches
+                    return;
+                }
+
+                const wantedState = wantedStates[stateIndex];
+                const cursorReq = ev.target.source.openCursor(wantedState);
+                cursorReq.onsuccess = onsuccess;
+            }
+        }
+
+        const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly");
+        const store = txn.objectStore("outgoingRoomKeyRequests");
+
+        const wantedState = wantedStates[stateIndex];
+        const cursorReq = store.index("state").openCursor(wantedState);
+        cursorReq.onsuccess = onsuccess;
+
+        return promiseifyTxn(txn).then(() => results);
+    }
+
+    /**
+     * Look for an existing room key request by id and state, and update it if
+     * found
+     *
+     * @param {string} requestId      ID of request to update
+     * @param {number} expectedState  state we expect to find the request in
+     * @param {Object} updates        name/value map of updates to apply
+     *
+     * @returns {Promise} resolves to
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}
+     *    updated request, or null if no matching row was found
+     */
+    updateOutgoingRoomKeyRequest(requestId, expectedState, updates) {
+        let result = null;
+
+        function onsuccess(ev) {
+            const cursor = ev.target.result;
+            if (!cursor) {
+                return;
+            }
+            const data = cursor.value;
+            if (data.state != expectedState) {
+                _logger2.default.warn(`Cannot update room key request from ${expectedState} ` + `as it was already updated to ${data.state}`);
+                return;
+            }
+            Object.assign(data, updates);
+            cursor.update(data);
+            result = data;
+        }
+
+        const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite");
+        const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
+        cursorReq.onsuccess = onsuccess;
+        return promiseifyTxn(txn).then(() => result);
+    }
+
+    /**
+     * Look for an existing room key request by id and state, and delete it if
+     * found
+     *
+     * @param {string} requestId      ID of request to update
+     * @param {number} expectedState  state we expect to find the request in
+     *
+     * @returns {Promise} resolves once the operation is completed
+     */
+    deleteOutgoingRoomKeyRequest(requestId, expectedState) {
+        const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite");
+        const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
+        cursorReq.onsuccess = ev => {
+            const cursor = ev.target.result;
+            if (!cursor) {
+                return;
+            }
+            const data = cursor.value;
+            if (data.state != expectedState) {
+                _logger2.default.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`);
+                return;
+            }
+            cursor.delete();
+        };
+        return promiseifyTxn(txn);
+    }
+
+    // Olm Account
+
+    getAccount(txn, func) {
+        const objectStore = txn.objectStore("account");
+        const getReq = objectStore.get("-");
+        getReq.onsuccess = function () {
+            try {
+                func(getReq.result || null);
+            } catch (e) {
+                abortWithException(txn, e);
+            }
+        };
+    }
+
+    storeAccount(txn, newData) {
+        const objectStore = txn.objectStore("account");
+        objectStore.put(newData, "-");
+    }
+
+    // Olm Sessions
+
+    countEndToEndSessions(txn, func) {
+        const objectStore = txn.objectStore("sessions");
+        const countReq = objectStore.count();
+        countReq.onsuccess = function () {
+            func(countReq.result);
+        };
+    }
+
+    getEndToEndSessions(deviceKey, txn, func) {
+        const objectStore = txn.objectStore("sessions");
+        const idx = objectStore.index("deviceKey");
+        const getReq = idx.openCursor(deviceKey);
+        const results = {};
+        getReq.onsuccess = function () {
+            const cursor = getReq.result;
+            if (cursor) {
+                results[cursor.value.sessionId] = {
+                    session: cursor.value.session,
+                    lastReceivedMessageTs: cursor.value.lastReceivedMessageTs
+                };
+                cursor.continue();
+            } else {
+                try {
+                    func(results);
+                } catch (e) {
+                    abortWithException(txn, e);
+                }
+            }
+        };
+    }
+
+    getEndToEndSession(deviceKey, sessionId, txn, func) {
+        const objectStore = txn.objectStore("sessions");
+        const getReq = objectStore.get([deviceKey, sessionId]);
+        getReq.onsuccess = function () {
+            try {
+                if (getReq.result) {
+                    func({
+                        session: getReq.result.session,
+                        lastReceivedMessageTs: getReq.result.lastReceivedMessageTs
+                    });
+                } else {
+                    func(null);
+                }
+            } catch (e) {
+                abortWithException(txn, e);
+            }
+        };
+    }
+
+    getAllEndToEndSessions(txn, func) {
+        const objectStore = txn.objectStore("sessions");
+        const getReq = objectStore.openCursor();
+        getReq.onsuccess = function () {
+            const cursor = getReq.result;
+            if (cursor) {
+                func(cursor.value);
+                cursor.continue();
+            } else {
+                try {
+                    func(null);
+                } catch (e) {
+                    abortWithException(txn, e);
+                }
+            }
+        };
+    }
+
+    storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+        const objectStore = txn.objectStore("sessions");
+        objectStore.put({
+            deviceKey,
+            sessionId,
+            session: sessionInfo.session,
+            lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs
+        });
+    }
+
+    // Inbound group sessions
+
+    getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+        const objectStore = txn.objectStore("inbound_group_sessions");
+        const getReq = objectStore.get([senderCurve25519Key, sessionId]);
+        getReq.onsuccess = function () {
+            try {
+                if (getReq.result) {
+                    func(getReq.result.session);
+                } else {
+                    func(null);
+                }
+            } catch (e) {
+                abortWithException(txn, e);
+            }
+        };
+    }
+
+    getAllEndToEndInboundGroupSessions(txn, func) {
+        const objectStore = txn.objectStore("inbound_group_sessions");
+        const getReq = objectStore.openCursor();
+        getReq.onsuccess = function () {
+            const cursor = getReq.result;
+            if (cursor) {
+                try {
+                    func({
+                        senderKey: cursor.value.senderCurve25519Key,
+                        sessionId: cursor.value.sessionId,
+                        sessionData: cursor.value.session
+                    });
+                } catch (e) {
+                    abortWithException(txn, e);
+                }
+                cursor.continue();
+            } else {
+                try {
+                    func(null);
+                } catch (e) {
+                    abortWithException(txn, e);
+                }
+            }
+        };
+    }
+
+    addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        const objectStore = txn.objectStore("inbound_group_sessions");
+        const addReq = objectStore.add({
+            senderCurve25519Key, sessionId, session: sessionData
+        });
+        addReq.onerror = ev => {
+            if (addReq.error.name === 'ConstraintError') {
+                // This stops the error from triggering the txn's onerror
+                ev.stopPropagation();
+                // ...and this stops it from aborting the transaction
+                ev.preventDefault();
+                _logger2.default.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId);
+            } else {
+                abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error));
+            }
+        };
+    }
+
+    storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        const objectStore = txn.objectStore("inbound_group_sessions");
+        objectStore.put({
+            senderCurve25519Key, sessionId, session: sessionData
+        });
+    }
+
+    getEndToEndDeviceData(txn, func) {
+        const objectStore = txn.objectStore("device_data");
+        const getReq = objectStore.get("-");
+        getReq.onsuccess = function () {
+            try {
+                func(getReq.result || null);
+            } catch (e) {
+                abortWithException(txn, e);
+            }
+        };
+    }
+
+    storeEndToEndDeviceData(deviceData, txn) {
+        const objectStore = txn.objectStore("device_data");
+        objectStore.put(deviceData, "-");
+    }
+
+    storeEndToEndRoom(roomId, roomInfo, txn) {
+        const objectStore = txn.objectStore("rooms");
+        objectStore.put(roomInfo, roomId);
+    }
+
+    getEndToEndRooms(txn, func) {
+        const rooms = {};
+        const objectStore = txn.objectStore("rooms");
+        const getReq = objectStore.openCursor();
+        getReq.onsuccess = function () {
+            const cursor = getReq.result;
+            if (cursor) {
+                rooms[cursor.key] = cursor.value;
+                cursor.continue();
+            } else {
+                try {
+                    func(rooms);
+                } catch (e) {
+                    abortWithException(txn, e);
+                }
+            }
+        };
+    }
+
+    // session backups
+
+    getSessionsNeedingBackup(limit) {
+        return new _bluebird2.default((resolve, reject) => {
+            const sessions = [];
+
+            const txn = this._db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly");
+            txn.onerror = reject;
+            txn.oncomplete = function () {
+                resolve(sessions);
+            };
+            const objectStore = txn.objectStore("sessions_needing_backup");
+            const sessionStore = txn.objectStore("inbound_group_sessions");
+            const getReq = objectStore.openCursor();
+            getReq.onsuccess = function () {
+                const cursor = getReq.result;
+                if (cursor) {
+                    const sessionGetReq = sessionStore.get(cursor.key);
+                    sessionGetReq.onsuccess = function () {
+                        sessions.push({
+                            senderKey: sessionGetReq.result.senderCurve25519Key,
+                            sessionId: sessionGetReq.result.sessionId,
+                            sessionData: sessionGetReq.result.session
+                        });
+                    };
+                    if (!limit || sessions.length < limit) {
+                        cursor.continue();
+                    }
+                }
+            };
+        });
+    }
+
+    countSessionsNeedingBackup(txn) {
+        if (!txn) {
+            txn = this._db.transaction("sessions_needing_backup", "readonly");
+        }
+        const objectStore = txn.objectStore("sessions_needing_backup");
+        return new _bluebird2.default((resolve, reject) => {
+            const req = objectStore.count();
+            req.onerror = reject;
+            req.onsuccess = () => resolve(req.result);
+        });
+    }
+
+    unmarkSessionsNeedingBackup(sessions, txn) {
+        if (!txn) {
+            txn = this._db.transaction("sessions_needing_backup", "readwrite");
+        }
+        const objectStore = txn.objectStore("sessions_needing_backup");
+        return _bluebird2.default.all(sessions.map(session => {
+            return new _bluebird2.default((resolve, reject) => {
+                const req = objectStore.delete([session.senderKey, session.sessionId]);
+                req.onsuccess = resolve;
+                req.onerror = reject;
+            });
+        }));
+    }
+
+    markSessionsNeedingBackup(sessions, txn) {
+        if (!txn) {
+            txn = this._db.transaction("sessions_needing_backup", "readwrite");
+        }
+        const objectStore = txn.objectStore("sessions_needing_backup");
+        return _bluebird2.default.all(sessions.map(session => {
+            return new _bluebird2.default((resolve, reject) => {
+                const req = objectStore.put({
+                    senderCurve25519Key: session.senderKey,
+                    sessionId: session.sessionId
+                });
+                req.onsuccess = resolve;
+                req.onerror = reject;
+            });
+        }));
+    }
+
+    doTxn(mode, stores, func) {
+        const txn = this._db.transaction(stores, mode);
+        const promise = promiseifyTxn(txn);
+        const result = func(txn);
+        return promise.then(() => {
+            return result;
+        });
+    }
+}
+
+exports.Backend = Backend;
+function upgradeDatabase(db, oldVersion) {
+    _logger2.default.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`);
+    if (oldVersion < 1) {
+        // The database did not previously exist.
+        createDatabase(db);
+    }
+    if (oldVersion < 2) {
+        db.createObjectStore("account");
+    }
+    if (oldVersion < 3) {
+        const sessionsStore = db.createObjectStore("sessions", {
+            keyPath: ["deviceKey", "sessionId"]
+        });
+        sessionsStore.createIndex("deviceKey", "deviceKey");
+    }
+    if (oldVersion < 4) {
+        db.createObjectStore("inbound_group_sessions", {
+            keyPath: ["senderCurve25519Key", "sessionId"]
+        });
+    }
+    if (oldVersion < 5) {
+        db.createObjectStore("device_data");
+    }
+    if (oldVersion < 6) {
+        db.createObjectStore("rooms");
+    }
+    if (oldVersion < 7) {
+        db.createObjectStore("sessions_needing_backup", {
+            keyPath: ["senderCurve25519Key", "sessionId"]
+        });
+    }
+    // Expand as needed.
+}
+
+function createDatabase(db) {
+    const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
+
+    // we assume that the RoomKeyRequestBody will have room_id and session_id
+    // properties, to make the index efficient.
+    outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
+
+    outgoingRoomKeyRequestsStore.createIndex("state", "state");
+}
+
+/*
+ * Aborts a transaction with a given exception
+ * The transaction promise will be rejected with this exception.
+ */
+function abortWithException(txn, e) {
+    // We cheekily stick our exception onto the transaction object here
+    // We could alternatively make the thing we pass back to the app
+    // an object containing the transaction and exception.
+    txn._mx_abortexception = e;
+    try {
+        txn.abort();
+    } catch (e) {
+        // sometimes we won't be able to abort the transaction
+        // (ie. if it's aborted or completed)
+    }
+}
+
+function promiseifyTxn(txn) {
+    return new _bluebird2.default((resolve, reject) => {
+        txn.oncomplete = () => {
+            if (txn._mx_abortexception !== undefined) {
+                reject(txn._mx_abortexception);
+            }
+            resolve();
+        };
+        txn.onerror = event => {
+            if (txn._mx_abortexception !== undefined) {
+                reject(txn._mx_abortexception);
+            } else {
+                _logger2.default.log("Error performing indexeddb txn", event);
+                reject(event.target.error);
+            }
+        };
+        txn.onabort = event => {
+            if (txn._mx_abortexception !== undefined) {
+                reject(txn._mx_abortexception);
+            } else {
+                _logger2.default.log("Error performing indexeddb txn", event);
+                reject(event.target.error);
+            }
+        };
+    });
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/store/indexeddb-crypto-store.js
@@ -0,0 +1,558 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _localStorageCryptoStore = require('./localStorage-crypto-store');
+
+var _localStorageCryptoStore2 = _interopRequireDefault(_localStorageCryptoStore);
+
+var _memoryCryptoStore = require('./memory-crypto-store');
+
+var _memoryCryptoStore2 = _interopRequireDefault(_memoryCryptoStore);
+
+var _indexeddbCryptoStoreBackend = require('./indexeddb-crypto-store-backend');
+
+var IndexedDBCryptoStoreBackend = _interopRequireWildcard(_indexeddbCryptoStoreBackend);
+
+var _errors = require('../../errors');
+
+var _indexeddbHelpers = require('../../indexeddb-helpers');
+
+var IndexedDBHelpers = _interopRequireWildcard(_indexeddbHelpers);
+
+function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * Internal module. indexeddb storage for e2e.
+ *
+ * @module
+ */
+
+/**
+ * An implementation of CryptoStore, which is normally backed by an indexeddb,
+ * but with fallback to MemoryCryptoStore.
+ *
+ * @implements {module:crypto/store/base~CryptoStore}
+ */
+class IndexedDBCryptoStore {
+    /**
+     * Create a new IndexedDBCryptoStore
+     *
+     * @param {IDBFactory} indexedDB  global indexedDB instance
+     * @param {string} dbName   name of db to connect to
+     */
+    constructor(indexedDB, dbName) {
+        this._indexedDB = indexedDB;
+        this._dbName = dbName;
+        this._backendPromise = null;
+    }
+
+    static exists(indexedDB, dbName) {
+        return IndexedDBHelpers.exists(indexedDB, dbName);
+    }
+
+    /**
+     * Ensure the database exists and is up-to-date, or fall back to
+     * a local storage or in-memory store.
+     *
+     * @return {Promise} resolves to either an IndexedDBCryptoStoreBackend.Backend,
+     * or a MemoryCryptoStore
+     */
+    _connect() {
+        if (this._backendPromise) {
+            return this._backendPromise;
+        }
+
+        this._backendPromise = new _bluebird2.default((resolve, reject) => {
+            if (!this._indexedDB) {
+                reject(new Error('no indexeddb support available'));
+                return;
+            }
+
+            _logger2.default.log(`connecting to indexeddb ${this._dbName}`);
+
+            const req = this._indexedDB.open(this._dbName, IndexedDBCryptoStoreBackend.VERSION);
+
+            req.onupgradeneeded = ev => {
+                const db = ev.target.result;
+                const oldVersion = ev.oldVersion;
+                IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion);
+            };
+
+            req.onblocked = () => {
+                _logger2.default.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`);
+            };
+
+            req.onerror = ev => {
+                _logger2.default.log("Error connecting to indexeddb", ev);
+                reject(ev.target.error);
+            };
+
+            req.onsuccess = r => {
+                const db = r.target.result;
+
+                _logger2.default.log(`connected to indexeddb ${this._dbName}`);
+                resolve(new IndexedDBCryptoStoreBackend.Backend(db));
+            };
+        }).then(backend => {
+            // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively.
+            // Try a dummy query which will fail if the browser doesn't support compund keys, so
+            // we can fall back to a different backend.
+            return backend.doTxn('readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], txn => {
+                backend.getEndToEndInboundGroupSession('', '', txn, () => {});
+            }).then(() => {
+                return backend;
+            });
+        }).catch(e => {
+            if (e.name === 'VersionError') {
+                _logger2.default.warn("Crypto DB is too new for us to use!", e);
+                // don't fall back to a different store: the user has crypto data
+                // in this db so we should use it or nothing at all.
+                throw new _errors.InvalidCryptoStoreError(_errors.InvalidCryptoStoreError.TOO_NEW);
+            }
+            _logger2.default.warn(`unable to connect to indexeddb ${this._dbName}` + `: falling back to localStorage store: ${e}`);
+
+            try {
+                return new _localStorageCryptoStore2.default(global.localStorage);
+            } catch (e) {
+                _logger2.default.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
+                return new _memoryCryptoStore2.default();
+            }
+        });
+
+        return this._backendPromise;
+    }
+
+    /**
+     * Delete all data from this store.
+     *
+     * @returns {Promise} resolves when the store has been cleared.
+     */
+    deleteAllData() {
+        return new _bluebird2.default((resolve, reject) => {
+            if (!this._indexedDB) {
+                reject(new Error('no indexeddb support available'));
+                return;
+            }
+
+            _logger2.default.log(`Removing indexeddb instance: ${this._dbName}`);
+            const req = this._indexedDB.deleteDatabase(this._dbName);
+
+            req.onblocked = () => {
+                _logger2.default.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`);
+            };
+
+            req.onerror = ev => {
+                _logger2.default.log("Error deleting data from indexeddb", ev);
+                reject(ev.target.error);
+            };
+
+            req.onsuccess = () => {
+                _logger2.default.log(`Removed indexeddb instance: ${this._dbName}`);
+                resolve();
+            };
+        }).catch(e => {
+            // in firefox, with indexedDB disabled, this fails with a
+            // DOMError. We treat this as non-fatal, so that people can
+            // still use the app.
+            _logger2.default.warn(`unable to delete IndexedDBCryptoStore: ${e}`);
+        });
+    }
+
+    /**
+     * Look for an existing outgoing room key request, and if none is found,
+     * add a new one
+     *
+     * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request
+     *
+     * @returns {Promise} resolves to
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the
+     *    same instance as passed in, or the existing one.
+     */
+    getOrAddOutgoingRoomKeyRequest(request) {
+        return this._connect().then(backend => {
+            return backend.getOrAddOutgoingRoomKeyRequest(request);
+        });
+    }
+
+    /**
+     * Look for an existing room key request
+     *
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     *    existing request to look for
+     *
+     * @return {Promise} resolves to the matching
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    not found
+     */
+    getOutgoingRoomKeyRequest(requestBody) {
+        return this._connect().then(backend => {
+            return backend.getOutgoingRoomKeyRequest(requestBody);
+        });
+    }
+
+    /**
+     * Look for room key requests by state
+     *
+     * @param {Array<Number>} wantedStates list of acceptable states
+     *
+     * @return {Promise} resolves to the a
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    there are no pending requests in those states. If there are multiple
+     *    requests in those states, an arbitrary one is chosen.
+     */
+    getOutgoingRoomKeyRequestByState(wantedStates) {
+        return this._connect().then(backend => {
+            return backend.getOutgoingRoomKeyRequestByState(wantedStates);
+        });
+    }
+
+    /**
+     * Look for room key requests by target device and state
+     *
+     * @param {string} userId Target user ID
+     * @param {string} deviceId Target device ID
+     * @param {Array<Number>} wantedStates list of acceptable states
+     *
+     * @return {Promise} resolves to a list of all the
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}
+     */
+    getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
+        return this._connect().then(backend => {
+            return backend.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates);
+        });
+    }
+
+    /**
+     * Look for an existing room key request by id and state, and update it if
+     * found
+     *
+     * @param {string} requestId      ID of request to update
+     * @param {number} expectedState  state we expect to find the request in
+     * @param {Object} updates        name/value map of updates to apply
+     *
+     * @returns {Promise} resolves to
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}
+     *    updated request, or null if no matching row was found
+     */
+    updateOutgoingRoomKeyRequest(requestId, expectedState, updates) {
+        return this._connect().then(backend => {
+            return backend.updateOutgoingRoomKeyRequest(requestId, expectedState, updates);
+        });
+    }
+
+    /**
+     * Look for an existing room key request by id and state, and delete it if
+     * found
+     *
+     * @param {string} requestId      ID of request to update
+     * @param {number} expectedState  state we expect to find the request in
+     *
+     * @returns {Promise} resolves once the operation is completed
+     */
+    deleteOutgoingRoomKeyRequest(requestId, expectedState) {
+        return this._connect().then(backend => {
+            return backend.deleteOutgoingRoomKeyRequest(requestId, expectedState);
+        });
+    }
+
+    // Olm Account
+
+    /*
+     * Get the account pickle from the store.
+     * This requires an active transaction. See doTxn().
+     *
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(string)} func Called with the account pickle
+     */
+    getAccount(txn, func) {
+        this._backendPromise.value().getAccount(txn, func);
+    }
+
+    /*
+     * Write the account pickle to the store.
+     * This requires an active transaction. See doTxn().
+     *
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {string} newData The new account pickle to store.
+     */
+    storeAccount(txn, newData) {
+        this._backendPromise.value().storeAccount(txn, newData);
+    }
+
+    // Olm sessions
+
+    /**
+     * Returns the number of end-to-end sessions in the store
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(int)} func Called with the count of sessions
+     */
+    countEndToEndSessions(txn, func) {
+        this._backendPromise.value().countEndToEndSessions(txn, func);
+    }
+
+    /**
+     * Retrieve a specific end-to-end session between the logged-in user
+     * and another device.
+     * @param {string} deviceKey The public key of the other device.
+     * @param {string} sessionId The ID of the session to retrieve
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(object)} func Called with A map from sessionId
+     *     to session information object with 'session' key being the
+     *     Base64 end-to-end session and lastReceivedMessageTs being the
+     *     timestamp in milliseconds at which the session last received
+     *     a message.
+     */
+    getEndToEndSession(deviceKey, sessionId, txn, func) {
+        this._backendPromise.value().getEndToEndSession(deviceKey, sessionId, txn, func);
+    }
+
+    /**
+     * Retrieve the end-to-end sessions between the logged-in user and another
+     * device.
+     * @param {string} deviceKey The public key of the other device.
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(object)} func Called with A map from sessionId
+     *     to session information object with 'session' key being the
+     *     Base64 end-to-end session and lastReceivedMessageTs being the
+     *     timestamp in milliseconds at which the session last received
+     *     a message.
+     */
+    getEndToEndSessions(deviceKey, txn, func) {
+        this._backendPromise.value().getEndToEndSessions(deviceKey, txn, func);
+    }
+
+    /**
+     * Retrieve all end-to-end sessions
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(object)} func Called one for each session with
+     *     an object with, deviceKey, lastReceivedMessageTs, sessionId
+     *     and session keys.
+     */
+    getAllEndToEndSessions(txn, func) {
+        this._backendPromise.value().getAllEndToEndSessions(txn, func);
+    }
+
+    /**
+     * Store a session between the logged-in user and another device
+     * @param {string} deviceKey The public key of the other device.
+     * @param {string} sessionId The ID for this end-to-end session.
+     * @param {string} sessionInfo Session information object
+     * @param {*} txn An active transaction. See doTxn().
+     */
+    storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+        this._backendPromise.value().storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn);
+    }
+
+    // Inbound group saessions
+
+    /**
+     * Retrieve the end-to-end inbound group session for a given
+     * server key and session ID
+     * @param {string} senderCurve25519Key The sender's curve 25519 key
+     * @param {string} sessionId The ID of the session
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(object)} func Called with A map from sessionId
+     *     to Base64 end-to-end session.
+     */
+    getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+        this._backendPromise.value().getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func);
+    }
+
+    /**
+     * Fetches all inbound group sessions in the store
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(object)} func Called once for each group session
+     *     in the store with an object having keys {senderKey, sessionId,
+     *     sessionData}, then once with null to indicate the end of the list.
+     */
+    getAllEndToEndInboundGroupSessions(txn, func) {
+        this._backendPromise.value().getAllEndToEndInboundGroupSessions(txn, func);
+    }
+
+    /**
+     * Adds an end-to-end inbound group session to the store.
+     * If there already exists an inbound group session with the same
+     * senderCurve25519Key and sessionID, the session will not be added.
+     * @param {string} senderCurve25519Key The sender's curve 25519 key
+     * @param {string} sessionId The ID of the session
+     * @param {object} sessionData The session data structure
+     * @param {*} txn An active transaction. See doTxn().
+     */
+    addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        this._backendPromise.value().addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+    }
+
+    /**
+     * Writes an end-to-end inbound group session to the store.
+     * If there already exists an inbound group session with the same
+     * senderCurve25519Key and sessionID, it will be overwritten.
+     * @param {string} senderCurve25519Key The sender's curve 25519 key
+     * @param {string} sessionId The ID of the session
+     * @param {object} sessionData The session data structure
+     * @param {*} txn An active transaction. See doTxn().
+     */
+    storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        this._backendPromise.value().storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+    }
+
+    // End-to-end device tracking
+
+    /**
+     * Store the state of all tracked devices
+     * This contains devices for each user, a tracking state for each user
+     * and a sync token matching the point in time the snapshot represents.
+     * These all need to be written out in full each time such that the snapshot
+     * is always consistent, so they are stored in one object.
+     *
+     * @param {Object} deviceData
+     * @param {*} txn An active transaction. See doTxn().
+     */
+    storeEndToEndDeviceData(deviceData, txn) {
+        this._backendPromise.value().storeEndToEndDeviceData(deviceData, txn);
+    }
+
+    /**
+     * Get the state of all tracked devices
+     *
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(Object)} func Function called with the
+     *     device data
+     */
+    getEndToEndDeviceData(txn, func) {
+        this._backendPromise.value().getEndToEndDeviceData(txn, func);
+    }
+
+    // End to End Rooms
+
+    /**
+     * Store the end-to-end state for a room.
+     * @param {string} roomId The room's ID.
+     * @param {object} roomInfo The end-to-end info for the room.
+     * @param {*} txn An active transaction. See doTxn().
+     */
+    storeEndToEndRoom(roomId, roomInfo, txn) {
+        this._backendPromise.value().storeEndToEndRoom(roomId, roomInfo, txn);
+    }
+
+    /**
+     * Get an object of roomId->roomInfo for all e2e rooms in the store
+     * @param {*} txn An active transaction. See doTxn().
+     * @param {function(Object)} func Function called with the end to end encrypted rooms
+     */
+    getEndToEndRooms(txn, func) {
+        this._backendPromise.value().getEndToEndRooms(txn, func);
+    }
+
+    // session backups
+
+    /**
+     * Get the inbound group sessions that need to be backed up.
+     * @param {integer} limit The maximum number of sessions to retrieve.  0
+     * for no limit.
+     * @returns {Promise} resolves to an array of inbound group sessions
+     */
+    getSessionsNeedingBackup(limit) {
+        return this._connect().then(backend => {
+            return backend.getSessionsNeedingBackup(limit);
+        });
+    }
+
+    /**
+     * Count the inbound group sessions that need to be backed up.
+     * @param {*} txn An active transaction. See doTxn(). (optional)
+     * @returns {Promise} resolves to the number of sessions
+     */
+    countSessionsNeedingBackup(txn) {
+        return this._connect().then(backend => {
+            return backend.countSessionsNeedingBackup(txn);
+        });
+    }
+
+    /**
+     * Unmark sessions as needing to be backed up.
+     * @param {Array<object>} sessions The sessions that need to be backed up.
+     * @param {*} txn An active transaction. See doTxn(). (optional)
+     * @returns {Promise} resolves when the sessions are unmarked
+     */
+    unmarkSessionsNeedingBackup(sessions, txn) {
+        return this._connect().then(backend => {
+            return backend.unmarkSessionsNeedingBackup(sessions, txn);
+        });
+    }
+
+    /**
+     * Mark sessions as needing to be backed up.
+     * @param {Array<object>} sessions The sessions that need to be backed up.
+     * @param {*} txn An active transaction. See doTxn(). (optional)
+     * @returns {Promise} resolves when the sessions are marked
+     */
+    markSessionsNeedingBackup(sessions, txn) {
+        return this._connect().then(backend => {
+            return backend.markSessionsNeedingBackup(sessions, txn);
+        });
+    }
+
+    /**
+     * Perform a transaction on the crypto store. Any store methods
+     * that require a transaction (txn) object to be passed in may
+     * only be called within a callback of either this function or
+     * one of the store functions operating on the same transaction.
+     *
+     * @param {string} mode 'readwrite' if you need to call setter
+     *     functions with this transaction. Otherwise, 'readonly'.
+     * @param {string[]} stores List IndexedDBCryptoStore.STORE_*
+     *     options representing all types of object that will be
+     *     accessed or written to with this transaction.
+     * @param {function(*)} func Function called with the
+     *     transaction object: an opaque object that should be passed
+     *     to store functions.
+     * @return {Promise} Promise that resolves with the result of the `func`
+     *     when the transaction is complete. If the backend is
+     *     async (ie. the indexeddb backend) any of the callback
+     *     functions throwing an exception will cause this promise to
+     *     reject with that exception. On synchronous backends, the
+     *     exception will propagate to the caller of the getFoo method.
+     */
+    doTxn(mode, stores, func) {
+        return this._connect().then(backend => {
+            return backend.doTxn(mode, stores, func);
+        });
+    }
+}
+
+exports.default = IndexedDBCryptoStore; /*
+                                        Copyright 2017 Vector Creations Ltd
+                                        Copyright 2018 New Vector Ltd
+                                        
+                                        Licensed under the Apache License, Version 2.0 (the "License");
+                                        you may not use this file except in compliance with the License.
+                                        You may obtain a copy of the License at
+                                        
+                                            http://www.apache.org/licenses/LICENSE-2.0
+                                        
+                                        Unless required by applicable law or agreed to in writing, software
+                                        distributed under the License is distributed on an "AS IS" BASIS,
+                                        WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                                        See the License for the specific language governing permissions and
+                                        limitations under the License.
+                                        */
+
+IndexedDBCryptoStore.STORE_ACCOUNT = 'account';
+IndexedDBCryptoStore.STORE_SESSIONS = 'sessions';
+IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions';
+IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data';
+IndexedDBCryptoStore.STORE_ROOMS = 'rooms';
+IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup';
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/store/localStorage-crypto-store.js
@@ -0,0 +1,288 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _memoryCryptoStore = require('./memory-crypto-store.js');
+
+var _memoryCryptoStore2 = _interopRequireDefault(_memoryCryptoStore);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * Internal module. Partial localStorage backed storage for e2e.
+ * This is not a full crypto store, just the in-memory store with
+ * some things backed by localStorage. It exists because indexedDB
+ * is broken in Firefox private mode or set to, "will not remember
+ * history".
+ *
+ * @module
+ */
+
+const E2E_PREFIX = "crypto."; /*
+                              Copyright 2017, 2018 New Vector Ltd
+                              
+                              Licensed under the Apache License, Version 2.0 (the "License");
+                              you may not use this file except in compliance with the License.
+                              You may obtain a copy of the License at
+                              
+                                  http://www.apache.org/licenses/LICENSE-2.0
+                              
+                              Unless required by applicable law or agreed to in writing, software
+                              distributed under the License is distributed on an "AS IS" BASIS,
+                              WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                              See the License for the specific language governing permissions and
+                              limitations under the License.
+                              */
+
+const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
+const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
+const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
+const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
+const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup";
+
+function keyEndToEndSessions(deviceKey) {
+    return E2E_PREFIX + "sessions/" + deviceKey;
+}
+
+function keyEndToEndInboundGroupSession(senderKey, sessionId) {
+    return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
+}
+
+function keyEndToEndRoomsPrefix(roomId) {
+    return KEY_ROOMS_PREFIX + roomId;
+}
+
+/**
+ * @implements {module:crypto/store/base~CryptoStore}
+ */
+class LocalStorageCryptoStore extends _memoryCryptoStore2.default {
+    constructor(webStore) {
+        super();
+        this.store = webStore;
+    }
+
+    static exists(webStore) {
+        const length = webStore.length;
+        for (let i = 0; i < length; i++) {
+            if (webStore.key(i).startsWith(E2E_PREFIX)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Olm Sessions
+
+    countEndToEndSessions(txn, func) {
+        let count = 0;
+        for (let i = 0; i < this.store.length; ++i) {
+            if (this.store.key(i).startsWith(keyEndToEndSessions(''))) ++count;
+        }
+        func(count);
+    }
+
+    _getEndToEndSessions(deviceKey, txn, func) {
+        const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey));
+        const fixedSessions = {};
+
+        // fix up any old sessions to be objects rather than just the base64 pickle
+        for (const [sid, val] of Object.entries(sessions || {})) {
+            if (typeof val === 'string') {
+                fixedSessions[sid] = {
+                    session: val
+                };
+            } else {
+                fixedSessions[sid] = val;
+            }
+        }
+
+        return fixedSessions;
+    }
+
+    getEndToEndSession(deviceKey, sessionId, txn, func) {
+        const sessions = this._getEndToEndSessions(deviceKey);
+        func(sessions[sessionId] || {});
+    }
+
+    getEndToEndSessions(deviceKey, txn, func) {
+        func(this._getEndToEndSessions(deviceKey) || {});
+    }
+
+    getAllEndToEndSessions(txn, func) {
+        for (let i = 0; i < this.store.length; ++i) {
+            if (this.store.key(i).startsWith(keyEndToEndSessions(''))) {
+                const deviceKey = this.store.key(i).split('/')[1];
+                for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) {
+                    func(sess);
+                }
+            }
+        }
+    }
+
+    storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+        const sessions = this._getEndToEndSessions(deviceKey) || {};
+        sessions[sessionId] = sessionInfo;
+        setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions);
+    }
+
+    // Inbound Group Sessions
+
+    getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+        func(getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)));
+    }
+
+    getAllEndToEndInboundGroupSessions(txn, func) {
+        for (let i = 0; i < this.store.length; ++i) {
+            const key = this.store.key(i);
+            if (key.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
+                // we can't use split, as the components we are trying to split out
+                // might themselves contain '/' characters. We rely on the
+                // senderKey being a (32-byte) curve25519 key, base64-encoded
+                // (hence 43 characters long).
+
+                func({
+                    senderKey: key.substr(KEY_INBOUND_SESSION_PREFIX.length, 43),
+                    sessionId: key.substr(KEY_INBOUND_SESSION_PREFIX.length + 44),
+                    sessionData: getJsonItem(this.store, key)
+                });
+            }
+        }
+        func(null);
+    }
+
+    addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId));
+        if (!existing) {
+            this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+        }
+    }
+
+    storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData);
+    }
+
+    getEndToEndDeviceData(txn, func) {
+        func(getJsonItem(this.store, KEY_DEVICE_DATA));
+    }
+
+    storeEndToEndDeviceData(deviceData, txn) {
+        setJsonItem(this.store, KEY_DEVICE_DATA, deviceData);
+    }
+
+    storeEndToEndRoom(roomId, roomInfo, txn) {
+        setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo);
+    }
+
+    getEndToEndRooms(txn, func) {
+        const result = {};
+        const prefix = keyEndToEndRoomsPrefix('');
+
+        for (let i = 0; i < this.store.length; ++i) {
+            const key = this.store.key(i);
+            if (key.startsWith(prefix)) {
+                const roomId = key.substr(prefix.length);
+                result[roomId] = getJsonItem(this.store, key);
+            }
+        }
+        func(result);
+    }
+
+    getSessionsNeedingBackup(limit) {
+        const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+        const sessions = [];
+
+        for (const session in sessionsNeedingBackup) {
+            if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
+                // see getAllEndToEndInboundGroupSessions for the magic number explanations
+                const senderKey = session.substr(0, 43);
+                const sessionId = session.substr(44);
+                this.getEndToEndInboundGroupSession(senderKey, sessionId, null, sessionData => {
+                    sessions.push({
+                        senderKey: senderKey,
+                        sessionId: sessionId,
+                        sessionData: sessionData
+                    });
+                });
+                if (limit && session.length >= limit) {
+                    break;
+                }
+            }
+        }
+        return _bluebird2.default.resolve(sessions);
+    }
+
+    countSessionsNeedingBackup() {
+        const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+        return _bluebird2.default.resolve(Object.keys(sessionsNeedingBackup).length);
+    }
+
+    unmarkSessionsNeedingBackup(sessions) {
+        const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+        for (const session of sessions) {
+            delete sessionsNeedingBackup[session.senderKey + '/' + session.sessionId];
+        }
+        setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
+        return _bluebird2.default.resolve();
+    }
+
+    markSessionsNeedingBackup(sessions) {
+        const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+        for (const session of sessions) {
+            sessionsNeedingBackup[session.senderKey + '/' + session.sessionId] = true;
+        }
+        setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
+        return _bluebird2.default.resolve();
+    }
+
+    /**
+     * Delete all data from this store.
+     *
+     * @returns {Promise} Promise which resolves when the store has been cleared.
+     */
+    deleteAllData() {
+        this.store.removeItem(KEY_END_TO_END_ACCOUNT);
+        return _bluebird2.default.resolve();
+    }
+
+    // Olm account
+
+    getAccount(txn, func) {
+        const account = getJsonItem(this.store, KEY_END_TO_END_ACCOUNT);
+        func(account);
+    }
+
+    storeAccount(txn, newData) {
+        setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, newData);
+    }
+
+    doTxn(mode, stores, func) {
+        return _bluebird2.default.resolve(func(null));
+    }
+}
+
+exports.default = LocalStorageCryptoStore;
+function getJsonItem(store, key) {
+    try {
+        // if the key is absent, store.getItem() returns null, and
+        // JSON.parse(null) === null, so this returns null.
+        return JSON.parse(store.getItem(key));
+    } catch (e) {
+        _logger2.default.log("Error: Failed to get key %s: %s", key, e.stack || e);
+        _logger2.default.log(e.stack);
+    }
+    return null;
+}
+
+function setJsonItem(store, key, val) {
+    store.setItem(key, JSON.stringify(val));
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/store/memory-crypto-store.js
@@ -0,0 +1,365 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _utils = require('../../utils');
+
+var _utils2 = _interopRequireDefault(_utils);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * Internal module. in-memory storage for e2e.
+ *
+ * @module
+ */
+
+/**
+ * @implements {module:crypto/store/base~CryptoStore}
+ */
+class MemoryCryptoStore {
+    constructor() {
+        this._outgoingRoomKeyRequests = [];
+        this._account = null;
+
+        // Map of {devicekey -> {sessionId -> session pickle}}
+        this._sessions = {};
+        // Map of {senderCurve25519Key+'/'+sessionId -> session data object}
+        this._inboundGroupSessions = {};
+        // Opaque device data object
+        this._deviceData = null;
+        // roomId -> Opaque roomInfo object
+        this._rooms = {};
+        // Set of {senderCurve25519Key+'/'+sessionId}
+        this._sessionsNeedingBackup = {};
+    }
+
+    /**
+     * Delete all data from this store.
+     *
+     * @returns {Promise} Promise which resolves when the store has been cleared.
+     */
+    deleteAllData() {
+        return _bluebird2.default.resolve();
+    }
+
+    /**
+     * Look for an existing outgoing room key request, and if none is found,
+     * add a new one
+     *
+     * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request
+     *
+     * @returns {Promise} resolves to
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the
+     *    same instance as passed in, or the existing one.
+     */
+    getOrAddOutgoingRoomKeyRequest(request) {
+        const requestBody = request.requestBody;
+
+        return _bluebird2.default.try(() => {
+            // first see if we already have an entry for this request.
+            const existing = this._getOutgoingRoomKeyRequest(requestBody);
+
+            if (existing) {
+                // this entry matches the request - return it.
+                _logger2.default.log(`already have key request outstanding for ` + `${requestBody.room_id} / ${requestBody.session_id}: ` + `not sending another`);
+                return existing;
+            }
+
+            // we got to the end of the list without finding a match
+            // - add the new request.
+            _logger2.default.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
+            this._outgoingRoomKeyRequests.push(request);
+            return request;
+        });
+    }
+
+    /**
+     * Look for an existing room key request
+     *
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     *    existing request to look for
+     *
+     * @return {Promise} resolves to the matching
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    not found
+     */
+    getOutgoingRoomKeyRequest(requestBody) {
+        return _bluebird2.default.resolve(this._getOutgoingRoomKeyRequest(requestBody));
+    }
+
+    /**
+     * Looks for existing room key request, and returns the result synchronously.
+     *
+     * @internal
+     *
+     * @param {module:crypto~RoomKeyRequestBody} requestBody
+     *    existing request to look for
+     *
+     * @return {module:crypto/store/base~OutgoingRoomKeyRequest?}
+     *    the matching request, or null if not found
+     */
+    _getOutgoingRoomKeyRequest(requestBody) {
+        for (const existing of this._outgoingRoomKeyRequests) {
+            if (_utils2.default.deepCompare(existing.requestBody, requestBody)) {
+                return existing;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Look for room key requests by state
+     *
+     * @param {Array<Number>} wantedStates list of acceptable states
+     *
+     * @return {Promise} resolves to the a
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if
+     *    there are no pending requests in those states
+     */
+    getOutgoingRoomKeyRequestByState(wantedStates) {
+        for (const req of this._outgoingRoomKeyRequests) {
+            for (const state of wantedStates) {
+                if (req.state === state) {
+                    return _bluebird2.default.resolve(req);
+                }
+            }
+        }
+        return _bluebird2.default.resolve(null);
+    }
+
+    getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
+        const results = [];
+
+        for (const req of this._outgoingRoomKeyRequests) {
+            for (const state of wantedStates) {
+                if (req.state === state && req.recipients.includes({ userId, deviceId })) {
+                    results.push(req);
+                }
+            }
+        }
+        return _bluebird2.default.resolve(results);
+    }
+
+    /**
+     * Look for an existing room key request by id and state, and update it if
+     * found
+     *
+     * @param {string} requestId      ID of request to update
+     * @param {number} expectedState  state we expect to find the request in
+     * @param {Object} updates        name/value map of updates to apply
+     *
+     * @returns {Promise} resolves to
+     *    {@link module:crypto/store/base~OutgoingRoomKeyRequest}
+     *    updated request, or null if no matching row was found
+     */
+    updateOutgoingRoomKeyRequest(requestId, expectedState, updates) {
+        for (const req of this._outgoingRoomKeyRequests) {
+            if (req.requestId !== requestId) {
+                continue;
+            }
+
+            if (req.state != expectedState) {
+                _logger2.default.warn(`Cannot update room key request from ${expectedState} ` + `as it was already updated to ${req.state}`);
+                return _bluebird2.default.resolve(null);
+            }
+            Object.assign(req, updates);
+            return _bluebird2.default.resolve(req);
+        }
+
+        return _bluebird2.default.resolve(null);
+    }
+
+    /**
+     * Look for an existing room key request by id and state, and delete it if
+     * found
+     *
+     * @param {string} requestId      ID of request to update
+     * @param {number} expectedState  state we expect to find the request in
+     *
+     * @returns {Promise} resolves once the operation is completed
+     */
+    deleteOutgoingRoomKeyRequest(requestId, expectedState) {
+        for (let i = 0; i < this._outgoingRoomKeyRequests.length; i++) {
+            const req = this._outgoingRoomKeyRequests[i];
+
+            if (req.requestId !== requestId) {
+                continue;
+            }
+
+            if (req.state != expectedState) {
+                _logger2.default.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`);
+                return _bluebird2.default.resolve(null);
+            }
+
+            this._outgoingRoomKeyRequests.splice(i, 1);
+            return _bluebird2.default.resolve(req);
+        }
+
+        return _bluebird2.default.resolve(null);
+    }
+
+    // Olm Account
+
+    getAccount(txn, func) {
+        func(this._account);
+    }
+
+    storeAccount(txn, newData) {
+        this._account = newData;
+    }
+
+    // Olm Sessions
+
+    countEndToEndSessions(txn, func) {
+        return Object.keys(this._sessions).length;
+    }
+
+    getEndToEndSession(deviceKey, sessionId, txn, func) {
+        const deviceSessions = this._sessions[deviceKey] || {};
+        func(deviceSessions[sessionId] || null);
+    }
+
+    getEndToEndSessions(deviceKey, txn, func) {
+        func(this._sessions[deviceKey] || {});
+    }
+
+    getAllEndToEndSessions(txn, func) {
+        for (const deviceSessions of Object.values(this._sessions)) {
+            for (const sess of Object.values(deviceSessions)) {
+                func(sess);
+            }
+        }
+    }
+
+    storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
+        let deviceSessions = this._sessions[deviceKey];
+        if (deviceSessions === undefined) {
+            deviceSessions = {};
+            this._sessions[deviceKey] = deviceSessions;
+        }
+        deviceSessions[sessionId] = sessionInfo;
+    }
+
+    // Inbound Group Sessions
+
+    getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) {
+        func(this._inboundGroupSessions[senderCurve25519Key + '/' + sessionId] || null);
+    }
+
+    getAllEndToEndInboundGroupSessions(txn, func) {
+        for (const key of Object.keys(this._inboundGroupSessions)) {
+            // we can't use split, as the components we are trying to split out
+            // might themselves contain '/' characters. We rely on the
+            // senderKey being a (32-byte) curve25519 key, base64-encoded
+            // (hence 43 characters long).
+
+            func({
+                senderKey: key.substr(0, 43),
+                sessionId: key.substr(44),
+                sessionData: this._inboundGroupSessions[key]
+            });
+        }
+        func(null);
+    }
+
+    addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        const k = senderCurve25519Key + '/' + sessionId;
+        if (this._inboundGroupSessions[k] === undefined) {
+            this._inboundGroupSessions[k] = sessionData;
+        }
+    }
+
+    storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) {
+        this._inboundGroupSessions[senderCurve25519Key + '/' + sessionId] = sessionData;
+    }
+
+    // Device Data
+
+    getEndToEndDeviceData(txn, func) {
+        func(this._deviceData);
+    }
+
+    storeEndToEndDeviceData(deviceData, txn) {
+        this._deviceData = deviceData;
+    }
+
+    // E2E rooms
+
+    storeEndToEndRoom(roomId, roomInfo, txn) {
+        this._rooms[roomId] = roomInfo;
+    }
+
+    getEndToEndRooms(txn, func) {
+        func(this._rooms);
+    }
+
+    getSessionsNeedingBackup(limit) {
+        const sessions = [];
+        for (const session in this._sessionsNeedingBackup) {
+            if (this._inboundGroupSessions[session]) {
+                sessions.push({
+                    senderKey: session.substr(0, 43),
+                    sessionId: session.substr(44),
+                    sessionData: this._inboundGroupSessions[session]
+                });
+                if (limit && session.length >= limit) {
+                    break;
+                }
+            }
+        }
+        return _bluebird2.default.resolve(sessions);
+    }
+
+    countSessionsNeedingBackup() {
+        return _bluebird2.default.resolve(Object.keys(this._sessionsNeedingBackup).length);
+    }
+
+    unmarkSessionsNeedingBackup(sessions) {
+        for (const session of sessions) {
+            const sessionKey = session.senderKey + '/' + session.sessionId;
+            delete this._sessionsNeedingBackup[sessionKey];
+        }
+        return _bluebird2.default.resolve();
+    }
+
+    markSessionsNeedingBackup(sessions) {
+        for (const session of sessions) {
+            const sessionKey = session.senderKey + '/' + session.sessionId;
+            this._sessionsNeedingBackup[sessionKey] = true;
+        }
+        return _bluebird2.default.resolve();
+    }
+
+    // Session key backups
+
+    doTxn(mode, stores, func) {
+        return _bluebird2.default.resolve(func(null));
+    }
+}
+exports.default = MemoryCryptoStore; /*
+                                     Copyright 2017 Vector Creations Ltd
+                                     Copyright 2018 New Vector Ltd
+                                     
+                                     Licensed under the Apache License, Version 2.0 (the "License");
+                                     you may not use this file except in compliance with the License.
+                                     You may obtain a copy of the License at
+                                     
+                                         http://www.apache.org/licenses/LICENSE-2.0
+                                     
+                                     Unless required by applicable law or agreed to in writing, software
+                                     distributed under the License is distributed on an "AS IS" BASIS,
+                                     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                                     See the License for the specific language governing permissions and
+                                     limitations under the License.
+                                     */
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Base.js
@@ -0,0 +1,262 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _event = require('../../models/event');
+
+var _events = require('events');
+
+var _logger = require('../../logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _Error = require('./Error');
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/*
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Base class for verification methods.
+ * @module crypto/verification/Base
+ */
+
+const timeoutException = new Error("Verification timed out");
+
+class VerificationBase extends _events.EventEmitter {
+    /**
+     * Base class for verification methods.
+     *
+     * <p>Once a verifier object is created, the verification can be started by
+     * calling the verify() method, which will return a promise that will
+     * resolve when the verification is completed, or reject if it could not
+     * complete.</p>
+     *
+     * <p>Subclasses must have a NAME class property.</p>
+     *
+     * @class
+     *
+     * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
+     *
+     * @param {string} userId the user ID that is being verified
+     *
+     * @param {string} deviceId the device ID that is being verified
+     *
+     * @param {string} transactionId the transaction ID to be used when sending events
+     *
+     * @param {object} startEvent the m.key.verification.start event that
+     * initiated this verification, if any
+     *
+     * @param {object} request the key verification request object related to
+     * this verification, if any
+     *
+     * @param {object} parent parent verification for this verification, if any
+     */
+    constructor(baseApis, userId, deviceId, transactionId, startEvent, request, parent) {
+        super();
+        this._baseApis = baseApis;
+        this.userId = userId;
+        this.deviceId = deviceId;
+        this.transactionId = transactionId;
+        this.startEvent = startEvent;
+        this.request = request;
+        this.cancelled = false;
+        this._parent = parent;
+        this._done = false;
+        this._promise = null;
+        this._transactionTimeoutTimer = null;
+
+        // At this point, the verification request was received so start the timeout timer.
+        this._resetTimer();
+    }
+
+    _resetTimer() {
+        console.log("Refreshing/starting the verification transaction timeout timer");
+        if (this._transactionTimeoutTimer !== null) {
+            clearTimeout(this._transactionTimeoutTimer);
+        }
+        this._transactionTimeoutTimer = setTimeout(() => {
+            if (!this._done && !this.cancelled) {
+                console.log("Triggering verification timeout");
+                this.cancel(timeoutException);
+            }
+        }, 10 * 60 * 1000); // 10 minutes
+    }
+
+    _endTimer() {
+        if (this._transactionTimeoutTimer !== null) {
+            clearTimeout(this._transactionTimeoutTimer);
+            this._transactionTimeoutTimer = null;
+        }
+    }
+
+    _sendToDevice(type, content) {
+        if (this._done) {
+            return Promise.reject(new Error("Verification is already done"));
+        }
+        content.transaction_id = this.transactionId;
+        return this._baseApis.sendToDevice(type, {
+            [this.userId]: { [this.deviceId]: content }
+        });
+    }
+
+    _waitForEvent(type) {
+        if (this._done) {
+            return Promise.reject(new Error("Verification is already done"));
+        }
+        this._expectedEvent = type;
+        return new Promise((resolve, reject) => {
+            this._resolveEvent = resolve;
+            this._rejectEvent = reject;
+        });
+    }
+
+    handleEvent(e) {
+        if (this._done) {
+            return;
+        } else if (e.getType() === this._expectedEvent) {
+            this._expectedEvent = undefined;
+            this._rejectEvent = undefined;
+            this._resetTimer();
+            this._resolveEvent(e);
+        } else {
+            this._expectedEvent = undefined;
+            const exception = new Error("Unexpected message: expecting " + this._expectedEvent + " but got " + e.getType());
+            if (this._rejectEvent) {
+                const reject = this._rejectEvent;
+                this._rejectEvent = undefined;
+                reject(exception);
+            }
+            this.cancel(exception);
+        }
+    }
+
+    done() {
+        this._endTimer(); // always kill the activity timer
+        if (!this._done) {
+            this._resolve();
+        }
+    }
+
+    cancel(e) {
+        this._endTimer(); // always kill the activity timer
+        if (!this._done) {
+            this.cancelled = true;
+            if (this.userId && this.deviceId && this.transactionId) {
+                // send a cancellation to the other user (if it wasn't
+                // cancelled by the other user)
+                if (e === timeoutException) {
+                    const timeoutEvent = (0, _Error.newTimeoutError)();
+                    this._sendToDevice(timeoutEvent.getType(), timeoutEvent.getContent());
+                } else if (e instanceof _event.MatrixEvent) {
+                    const sender = e.getSender();
+                    if (sender !== this.userId) {
+                        const content = e.getContent();
+                        if (e.getType() === "m.key.verification.cancel") {
+                            content.code = content.code || "m.unknown";
+                            content.reason = content.reason || content.body || "Unknown reason";
+                            content.transaction_id = this.transactionId;
+                            this._sendToDevice("m.key.verification.cancel", content);
+                        } else {
+                            this._sendToDevice("m.key.verification.cancel", {
+                                code: "m.unknown",
+                                reason: content.body || "Unknown reason",
+                                transaction_id: this.transactionId
+                            });
+                        }
+                    }
+                } else {
+                    this._sendToDevice("m.key.verification.cancel", {
+                        code: "m.unknown",
+                        reason: e.toString(),
+                        transaction_id: this.transactionId
+                    });
+                }
+            }
+            if (this._promise !== null) {
+                // when we cancel without a promise, we end up with a promise
+                // but no reject function. If cancel is called again, we'd error.
+                if (this._reject) this._reject(e);
+            } else {
+                this._promise = Promise.reject(e);
+            }
+            // Also emit a 'cancel' event that the app can listen for to detect cancellation
+            // before calling verify()
+            this.emit('cancel', e);
+        }
+    }
+
+    /**
+     * Begin the key verification
+     *
+     * @returns {Promise} Promise which resolves when the verification has
+     *     completed.
+     */
+    verify() {
+        if (this._promise) return this._promise;
+
+        this._promise = new Promise((resolve, reject) => {
+            this._resolve = (...args) => {
+                this._done = true;
+                this._endTimer();
+                resolve(...args);
+            };
+            this._reject = (...args) => {
+                this._done = true;
+                this._endTimer();
+                reject(...args);
+            };
+        });
+        if (this._doVerification && !this._started) {
+            this._started = true;
+            this._resetTimer(); // restart the timeout
+            Promise.resolve(this._doVerification()).then(this.done.bind(this), this.cancel.bind(this));
+        }
+        return this._promise;
+    }
+
+    async _verifyKeys(userId, keys, verifier) {
+        // we try to verify all the keys that we're told about, but we might
+        // not know about all of them, so keep track of the keys that we know
+        // about, and ignore the rest
+        const verifiedDevices = [];
+
+        for (const [keyId, keyInfo] of Object.entries(keys)) {
+            const deviceId = keyId.split(':', 2)[1];
+            const device = await this._baseApis.getStoredDevice(userId, deviceId);
+            if (!device) {
+                _logger2.default.warn(`verification: Could not find device ${deviceId} to verify`);
+            } else {
+                await verifier(keyId, device, keyInfo);
+                verifiedDevices.push(deviceId);
+            }
+        }
+
+        // if none of the keys could be verified, then error because the app
+        // should be informed about that
+        if (!verifiedDevices.length) {
+            throw new Error("No devices could be verified");
+        }
+
+        for (const deviceId of verifiedDevices) {
+            await this._baseApis.setDeviceVerified(userId, deviceId);
+        }
+    }
+}
+exports.default = VerificationBase;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/Error.js
@@ -0,0 +1,86 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+exports.newInvalidMessageError = exports.newUserMismatchError = exports.newKeyMismatchError = exports.newUnexpectedMessageError = exports.newUnknownMethodError = exports.newUnknownTransactionError = exports.newTimeoutError = exports.newUserCancelledError = undefined;
+exports.newVerificationError = newVerificationError;
+exports.errorFactory = errorFactory;
+
+var _event = require("../../models/event");
+
+function newVerificationError(code, reason, extradata) {
+  extradata = extradata || {};
+  extradata.code = code;
+  extradata.reason = reason;
+  return new _event.MatrixEvent({
+    type: "m.key.verification.cancel",
+    content: extradata
+  });
+} /*
+  Copyright 2018 New Vector Ltd
+  
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+  
+      http://www.apache.org/licenses/LICENSE-2.0
+  
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  */
+
+/**
+ * Error messages.
+ *
+ * @module crypto/verification/Error
+ */
+
+function errorFactory(code, reason) {
+  return function (extradata) {
+    return newVerificationError(code, reason, extradata);
+  };
+}
+
+/**
+ * The verification was cancelled by the user.
+ */
+const newUserCancelledError = exports.newUserCancelledError = errorFactory("m.user", "Cancelled by user");
+
+/**
+ * The verification timed out.
+ */
+const newTimeoutError = exports.newTimeoutError = errorFactory("m.timeout", "Timed out");
+
+/**
+ * The transaction is unknown.
+ */
+const newUnknownTransactionError = exports.newUnknownTransactionError = errorFactory("m.unknown_transaction", "Unknown transaction");
+
+/**
+ * An unknown method was selected.
+ */
+const newUnknownMethodError = exports.newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method");
+
+/**
+ * An unexpected message was sent.
+ */
+const newUnexpectedMessageError = exports.newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message");
+
+/**
+ * The key does not match.
+ */
+const newKeyMismatchError = exports.newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch");
+
+/**
+ * The user does not match.
+ */
+const newUserMismatchError = exports.newUserMismatchError = errorFactory("m.user_error", "User mismatch");
+
+/**
+ * An invalid message was sent.
+ */
+const newInvalidMessageError = exports.newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message");
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/QRCode.js
@@ -0,0 +1,126 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.ScanQRCode = exports.ShowQRCode = undefined;
+
+var _Base = require("./Base");
+
+var _Base2 = _interopRequireDefault(_Base);
+
+var _Error = require("./Error");
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/*
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * QR code key verification.
+ * @module crypto/verification/QRCode
+ */
+
+const MATRIXTO_REGEXP = /^(?:https?:\/\/)?(?:www\.)?matrix\.to\/#\/([#@!+][^?]+)\?(.+)$/;
+const KEY_REGEXP = /^key_([^:]+:.+)$/;
+
+const newQRCodeError = (0, _Error.errorFactory)("m.qr_code.invalid", "Invalid QR code");
+
+/**
+ * @class crypto/verification/QRCode/ShowQRCode
+ * @extends {module:crypto/verification/Base}
+ */
+class ShowQRCode extends _Base2.default {
+    _doVerification() {
+        if (!this._done) {
+            const url = "https://matrix.to/#/" + this._baseApis.getUserId() + "?device=" + encodeURIComponent(this._baseApis.deviceId) + "&action=verify&key_ed25519%3A" + encodeURIComponent(this._baseApis.deviceId) + "=" + encodeURIComponent(this._baseApis.getDeviceEd25519Key());
+            this.emit("show_qr_code", {
+                url: url
+            });
+        }
+    }
+}
+
+exports.ShowQRCode = ShowQRCode;
+ShowQRCode.NAME = "m.qr_code.show.v1";
+
+/**
+ * @class crypto/verification/QRCode/ScanQRCode
+ * @extends {module:crypto/verification/Base}
+ */
+class ScanQRCode extends _Base2.default {
+    static factory(...args) {
+        return new ScanQRCode(...args);
+    }
+
+    async _doVerification() {
+        const code = await new Promise((resolve, reject) => {
+            this.emit("scan", {
+                done: resolve,
+                cancel: () => reject((0, _Error.newUserCancelledError)())
+            });
+        });
+
+        const match = code.match(MATRIXTO_REGEXP);
+        let deviceId;
+        const keys = {};
+        if (!match) {
+            throw newQRCodeError();
+        }
+        const userId = match[1];
+        const params = match[2].split("&").map(x => x.split("=", 2).map(decodeURIComponent));
+        let action;
+        for (const [name, value] of params) {
+            if (name === "device") {
+                deviceId = value;
+            } else if (name === "action") {
+                action = value;
+            } else {
+                const keyMatch = name.match(KEY_REGEXP);
+                if (keyMatch) {
+                    keys[keyMatch[1]] = value;
+                }
+            }
+        }
+        if (!deviceId || action !== "verify" || Object.keys(keys).length === 0) {
+            throw newQRCodeError();
+        }
+
+        if (!this.userId) {
+            await new Promise((resolve, reject) => {
+                this.emit("confirm_user_id", {
+                    userId: userId,
+                    confirm: resolve,
+                    cancel: () => reject((0, _Error.newUserMismatchError)())
+                });
+            });
+        } else if (this.userId !== userId) {
+            throw (0, _Error.newUserMismatchError)({
+                expected: this.userId,
+                actual: userId
+            });
+        }
+
+        await this._verifyKeys(userId, keys, (keyId, device, key) => {
+            if (device.keys[keyId] !== key) {
+                throw (0, _Error.newKeyMismatchError)();
+            }
+        });
+    }
+}
+
+exports.ScanQRCode = ScanQRCode;
+ScanQRCode.NAME = "m.qr_code.scan.v1";
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/crypto/verification/SAS.js
@@ -0,0 +1,337 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _Base = require('./Base');
+
+var _Base2 = _interopRequireDefault(_Base);
+
+var _anotherJson = require('another-json');
+
+var _anotherJson2 = _interopRequireDefault(_anotherJson);
+
+var _Error = require('./Error');
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const EVENTS = ["m.key.verification.accept", "m.key.verification.key", "m.key.verification.mac"]; /*
+                                                                                                  Copyright 2018 New Vector Ltd
+                                                                                                  
+                                                                                                  Licensed under the Apache License, Version 2.0 (the "License");
+                                                                                                  you may not use this file except in compliance with the License.
+                                                                                                  You may obtain a copy of the License at
+                                                                                                  
+                                                                                                      http://www.apache.org/licenses/LICENSE-2.0
+                                                                                                  
+                                                                                                  Unless required by applicable law or agreed to in writing, software
+                                                                                                  distributed under the License is distributed on an "AS IS" BASIS,
+                                                                                                  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                                                                                                  See the License for the specific language governing permissions and
+                                                                                                  limitations under the License.
+                                                                                                  */
+
+/**
+ * Short Authentication String (SAS) verification.
+ * @module crypto/verification/SAS
+ */
+
+let olmutil;
+
+const newMismatchedSASError = (0, _Error.errorFactory)("m.mismatched_sas", "Mismatched short authentication string");
+
+const newMismatchedCommitmentError = (0, _Error.errorFactory)("m.mismatched_commitment", "Mismatched commitment");
+
+function generateDecimalSas(sasBytes) {
+    /**
+     *      +--------+--------+--------+--------+--------+
+     *      | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
+     *      +--------+--------+--------+--------+--------+
+     * bits: 87654321 87654321 87654321 87654321 87654321
+     *       \____________/\_____________/\____________/
+     *         1st number    2nd number     3rd number
+     */
+    return [(sasBytes[0] << 5 | sasBytes[1] >> 3) + 1000, ((sasBytes[1] & 0x7) << 10 | sasBytes[2] << 2 | sasBytes[3] >> 6) + 1000, ((sasBytes[3] & 0x3f) << 7 | sasBytes[4] >> 1) + 1000];
+}
+
+const emojiMapping = [["🐶", "dog"], //  0
+["🐱", "cat"], //  1
+["🦁", "lion"], //  2
+["🐎", "horse"], //  3
+["🦄", "unicorn"], //  4
+["🐷", "pig"], //  5
+["🐘", "elephant"], //  6
+["🐰", "rabbit"], //  7
+["🐼", "panda"], //  8
+["🐓", "rooster"], //  9
+["🐧", "penguin"], // 10
+["🐢", "turtle"], // 11
+["🐟", "fish"], // 12
+["🐙", "octopus"], // 13
+["🦋", "butterfly"], // 14
+["🌷", "flower"], // 15
+["🌳", "tree"], // 16
+["🌵", "cactus"], // 17
+["🍄", "mushroom"], // 18
+["🌏", "globe"], // 19
+["🌙", "moon"], // 20
+["☁️", "cloud"], // 21
+["🔥", "fire"], // 22
+["🍌", "banana"], // 23
+["🍎", "apple"], // 24
+["🍓", "strawberry"], // 25
+["🌽", "corn"], // 26
+["🍕", "pizza"], // 27
+["🎂", "cake"], // 28
+["❤️", "heart"], // 29
+["🙂", "smiley"], // 30
+["🤖", "robot"], // 31
+["🎩", "hat"], // 32
+["👓", "glasses"], // 33
+["🔧", "spanner"], // 34
+["🎅", "santa"], // 35
+["👍", "thumbs up"], // 36
+["☂️", "umbrella"], // 37
+["⌛", "hourglass"], // 38
+["⏰", "clock"], // 39
+["🎁", "gift"], // 40
+["💡", "light bulb"], // 41
+["📕", "book"], // 42
+["✏️", "pencil"], // 43
+["📎", "paperclip"], // 44
+["✂️", "scissors"], // 45
+["🔒", "padlock"], // 46
+["🔑", "key"], // 47
+["🔨", "hammer"], // 48
+["☎️", "telephone"], // 49
+["🏁", "flag"], // 50
+["🚂", "train"], // 51
+["🚲", "bicycle"], // 52
+["✈️", "aeroplane"], // 53
+["🚀", "rocket"], // 54
+["🏆", "trophy"], // 55
+["⚽", "ball"], // 56
+["🎸", "guitar"], // 57
+["🎺", "trumpet"], // 58
+["🔔", "bell"], // 59
+["⚓️", "anchor"], // 60
+["🎧", "headphones"], // 61
+["📁", "folder"], // 62
+["📌", "pin"]];
+
+function generateEmojiSas(sasBytes) {
+    const emojis = [
+    // just like base64 encoding
+    sasBytes[0] >> 2, (sasBytes[0] & 0x3) << 4 | sasBytes[1] >> 4, (sasBytes[1] & 0xf) << 2 | sasBytes[2] >> 6, sasBytes[2] & 0x3f, sasBytes[3] >> 2, (sasBytes[3] & 0x3) << 4 | sasBytes[4] >> 4, (sasBytes[4] & 0xf) << 2 | sasBytes[5] >> 6];
+
+    return emojis.map(num => emojiMapping[num]);
+}
+
+const sasGenerators = {
+    decimal: generateDecimalSas,
+    emoji: generateEmojiSas
+};
+
+function generateSas(sasBytes, methods) {
+    const sas = {};
+    for (const method of methods) {
+        if (method in sasGenerators) {
+            sas[method] = sasGenerators[method](sasBytes);
+        }
+    }
+    return sas;
+}
+
+const macMethods = {
+    "hkdf-hmac-sha256": "calculate_mac",
+    "hmac-sha256": "calculate_mac_long_kdf"
+};
+
+/* lists of algorithms/methods that are supported.  The key agreement, hashes,
+ * and MAC lists should be sorted in order of preference (most preferred
+ * first).
+ */
+const KEY_AGREEMENT_LIST = ["curve25519"];
+const HASHES_LIST = ["sha256"];
+const MAC_LIST = ["hkdf-hmac-sha256", "hmac-sha256"];
+const SAS_LIST = Object.keys(sasGenerators);
+
+const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST);
+const HASHES_SET = new Set(HASHES_LIST);
+const MAC_SET = new Set(MAC_LIST);
+const SAS_SET = new Set(SAS_LIST);
+
+function intersection(anArray, aSet) {
+    return anArray instanceof Array ? anArray.filter(x => aSet.has(x)) : [];
+}
+
+/**
+ * @alias module:crypto/verification/SAS
+ * @extends {module:crypto/verification/Base}
+ */
+class SAS extends _Base2.default {
+    get events() {
+        return EVENTS;
+    }
+
+    async _doVerification() {
+        await global.Olm.init();
+        olmutil = olmutil || new global.Olm.Utility();
+
+        // make sure user's keys are downloaded
+        await this._baseApis.downloadKeys([this.userId]);
+
+        if (this.startEvent) {
+            return await this._doRespondVerification();
+        } else {
+            return await this._doSendVerification();
+        }
+    }
+
+    async _doSendVerification() {
+        const initialMessage = {
+            method: SAS.NAME,
+            from_device: this._baseApis.deviceId,
+            key_agreement_protocols: KEY_AGREEMENT_LIST,
+            hashes: HASHES_LIST,
+            message_authentication_codes: MAC_LIST,
+            // FIXME: allow app to specify what SAS methods can be used
+            short_authentication_string: SAS_LIST,
+            transaction_id: this.transactionId
+        };
+        this._sendToDevice("m.key.verification.start", initialMessage);
+
+        let e = await this._waitForEvent("m.key.verification.accept");
+        let content = e.getContent();
+        const sasMethods = intersection(content.short_authentication_string, SAS_SET);
+        if (!(KEY_AGREEMENT_SET.has(content.key_agreement_protocol) && HASHES_SET.has(content.hash) && MAC_SET.has(content.message_authentication_code) && sasMethods.length)) {
+            throw (0, _Error.newUnknownMethodError)();
+        }
+        if (typeof content.commitment !== "string") {
+            throw (0, _Error.newInvalidMessageError)();
+        }
+        const macMethod = content.message_authentication_code;
+        const hashCommitment = content.commitment;
+        const olmSAS = new global.Olm.SAS();
+        try {
+            this._sendToDevice("m.key.verification.key", {
+                key: olmSAS.get_pubkey()
+            });
+
+            e = await this._waitForEvent("m.key.verification.key");
+            // FIXME: make sure event is properly formed
+            content = e.getContent();
+            const commitmentStr = content.key + _anotherJson2.default.stringify(initialMessage);
+            // TODO: use selected hash function (when we support multiple)
+            if (olmutil.sha256(commitmentStr) !== hashCommitment) {
+                throw newMismatchedCommitmentError();
+            }
+            olmSAS.set_their_key(content.key);
+
+            const sasInfo = "MATRIX_KEY_VERIFICATION_SAS" + this._baseApis.getUserId() + this._baseApis.deviceId + this.userId + this.deviceId + this.transactionId;
+            const sasBytes = olmSAS.generate_bytes(sasInfo, 6);
+            const verifySAS = new Promise((resolve, reject) => {
+                this.emit("show_sas", {
+                    sas: generateSas(sasBytes, sasMethods),
+                    confirm: () => {
+                        this._sendMAC(olmSAS, macMethod);
+                        resolve();
+                    },
+                    cancel: () => reject((0, _Error.newUserCancelledError)()),
+                    mismatch: () => reject(newMismatchedSASError())
+                });
+            });
+
+            [e] = await Promise.all([this._waitForEvent("m.key.verification.mac"), verifySAS]);
+            content = e.getContent();
+            await this._checkMAC(olmSAS, content, macMethod);
+        } finally {
+            olmSAS.free();
+        }
+    }
+
+    async _doRespondVerification() {
+        let content = this.startEvent.getContent();
+        // Note: we intersect using our pre-made lists, rather than the sets,
+        // so that the result will be in our order of preference.  Then
+        // fetching the first element from the array will give our preferred
+        // method out of the ones offered by the other party.
+        const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0];
+        const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0];
+        const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0];
+        // FIXME: allow app to specify what SAS methods can be used
+        const sasMethods = intersection(content.short_authentication_string, SAS_SET);
+        if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) {
+            throw (0, _Error.newUnknownMethodError)();
+        }
+
+        const olmSAS = new global.Olm.SAS();
+        try {
+            const commitmentStr = olmSAS.get_pubkey() + _anotherJson2.default.stringify(content);
+            this._sendToDevice("m.key.verification.accept", {
+                key_agreement_protocol: keyAgreement,
+                hash: hashMethod,
+                message_authentication_code: macMethod,
+                short_authentication_string: sasMethods,
+                // TODO: use selected hash function (when we support multiple)
+                commitment: olmutil.sha256(commitmentStr)
+            });
+
+            let e = await this._waitForEvent("m.key.verification.key");
+            // FIXME: make sure event is properly formed
+            content = e.getContent();
+            olmSAS.set_their_key(content.key);
+            this._sendToDevice("m.key.verification.key", {
+                key: olmSAS.get_pubkey()
+            });
+
+            const sasInfo = "MATRIX_KEY_VERIFICATION_SAS" + this.userId + this.deviceId + this._baseApis.getUserId() + this._baseApis.deviceId + this.transactionId;
+            const sasBytes = olmSAS.generate_bytes(sasInfo, 6);
+            const verifySAS = new Promise((resolve, reject) => {
+                this.emit("show_sas", {
+                    sas: generateSas(sasBytes, sasMethods),
+                    confirm: () => {
+                        this._sendMAC(olmSAS, macMethod);
+                        resolve();
+                    },
+                    cancel: () => reject((0, _Error.newUserCancelledError)()),
+                    mismatch: () => reject(newMismatchedSASError())
+                });
+            });
+
+            [e] = await Promise.all([this._waitForEvent("m.key.verification.mac"), verifySAS]);
+            content = e.getContent();
+            await this._checkMAC(olmSAS, content, macMethod);
+        } finally {
+            olmSAS.free();
+        }
+    }
+
+    _sendMAC(olmSAS, method) {
+        const keyId = `ed25519:${this._baseApis.deviceId}`;
+        const mac = {};
+        const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + this._baseApis.getUserId() + this._baseApis.deviceId + this.userId + this.deviceId + this.transactionId;
+
+        mac[keyId] = olmSAS[macMethods[method]](this._baseApis.getDeviceEd25519Key(), baseInfo + keyId);
+        const keys = olmSAS[macMethods[method]](keyId, baseInfo + "KEY_IDS");
+        this._sendToDevice("m.key.verification.mac", { mac, keys });
+    }
+
+    async _checkMAC(olmSAS, content, method) {
+        const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + this.userId + this.deviceId + this._baseApis.getUserId() + this._baseApis.deviceId + this.transactionId;
+
+        if (content.keys !== olmSAS[macMethods[method]](Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS")) {
+            throw (0, _Error.newKeyMismatchError)();
+        }
+
+        await this._verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => {
+            if (keyInfo !== olmSAS[macMethods[method]](device.keys[keyId], baseInfo + keyId)) {
+                throw (0, _Error.newKeyMismatchError)();
+            }
+        });
+    }
+}
+
+exports.default = SAS;
+SAS.NAME = "m.sas.v1";
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/errors.js
@@ -0,0 +1,50 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+  value: true
+});
+exports.InvalidStoreError = InvalidStoreError;
+exports.InvalidCryptoStoreError = InvalidCryptoStoreError;
+// can't just do InvalidStoreError extends Error
+// because of http://babeljs.io/docs/usage/caveats/#classes
+function InvalidStoreError(reason, value) {
+  const message = `Store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`;
+  const instance = Reflect.construct(Error, [message]);
+  Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this));
+  instance.reason = reason;
+  instance.value = value;
+  return instance;
+}
+
+InvalidStoreError.TOGGLED_LAZY_LOADING = "TOGGLED_LAZY_LOADING";
+
+InvalidStoreError.prototype = Object.create(Error.prototype, {
+  constructor: {
+    value: Error,
+    enumerable: false,
+    writable: true,
+    configurable: true
+  }
+});
+Reflect.setPrototypeOf(InvalidStoreError, Error);
+
+function InvalidCryptoStoreError(reason) {
+  const message = `Crypto store is invalid because ${reason}, ` + `please stop the client, delete all data and start the client again`;
+  const instance = Reflect.construct(Error, [message]);
+  Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this));
+  instance.reason = reason;
+  instance.name = 'InvalidCryptoStoreError';
+  return instance;
+}
+
+InvalidCryptoStoreError.TOO_NEW = "TOO_NEW";
+
+InvalidCryptoStoreError.prototype = Object.create(Error.prototype, {
+  constructor: {
+    value: Error,
+    enumerable: false,
+    writable: true,
+    configurable: true
+  }
+});
+Reflect.setPrototypeOf(InvalidCryptoStoreError, Error);
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/filter-component.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/filter-component.js
@@ -20,36 +20,36 @@ limitations under the License.
 
 /**
  * Checks if a value matches a given field value, which may be a * terminated
  * wildcard pattern.
  * @param {String} actual_value  The value to be compared
  * @param {String} filter_value  The filter pattern to be compared
  * @return {bool} true if the actual_value matches the filter_value
  */
+
 function _matches_wildcard(actual_value, filter_value) {
     if (filter_value.endsWith("*")) {
-        var type_prefix = filter_value.slice(0, -1);
+        const type_prefix = filter_value.slice(0, -1);
         return actual_value.substr(0, type_prefix.length) === type_prefix;
-    }
-    else {
+    } else {
         return actual_value === filter_value;
     }
 }
 
 /**
  * FilterComponent is a section of a Filter definition which defines the
  * types, rooms, senders filters etc to be applied to a particular type of resource.
  * This is all ported over from synapse's Filter object.
  *
  * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as
  * 'Filters' are referred to as 'FilterCollections'.
  *
  * @constructor
- * @param {Object} the definition of this filter JSON, e.g. { 'contains_url': true }
+ * @param {Object} filter_json the definition of this filter JSON, e.g. { 'contains_url': true }
  */
 function FilterComponent(filter_json) {
     this.filter_json = filter_json;
 
     this.types = filter_json.types || null;
     this.not_types = filter_json.not_types || [];
 
     this.rooms = filter_json.rooms || null;
@@ -61,81 +61,81 @@ function FilterComponent(filter_json) {
     this.contains_url = filter_json.contains_url || null;
 }
 
 /**
  * Checks with the filter component matches the given event
  * @param {MatrixEvent} event event to be checked against the filter
  * @return {bool} true if the event matches the filter
  */
-FilterComponent.prototype.check = function(event) {
-    return this._checkFields(
-        event.getRoomId(),
-        event.getSender(),
-        event.getType(),
-        event.getContent() ? event.getContent().url !== undefined : false
-    );
+FilterComponent.prototype.check = function (event) {
+    return this._checkFields(event.getRoomId(), event.getSender(), event.getType(), event.getContent() ? event.getContent().url !== undefined : false);
 };
 
 /**
  * Checks whether the filter component matches the given event fields.
  * @param {String} room_id       the room_id for the event being checked
  * @param {String} sender        the sender of the event being checked
  * @param {String} event_type    the type of the event being checked
  * @param {String} contains_url  whether the event contains a content.url field
  * @return {bool} true if the event fields match the filter
  */
-FilterComponent.prototype._checkFields =
-    function(room_id, sender, event_type, contains_url)
-{
-    var literal_keys = {
-        "rooms": function(v) { return room_id === v; },
-        "senders": function(v) { return sender === v; },
-        "types": function(v) { return _matches_wildcard(event_type, v); },
+FilterComponent.prototype._checkFields = function (room_id, sender, event_type, contains_url) {
+    const literal_keys = {
+        "rooms": function (v) {
+            return room_id === v;
+        },
+        "senders": function (v) {
+            return sender === v;
+        },
+        "types": function (v) {
+            return _matches_wildcard(event_type, v);
+        }
     };
 
-    var self = this;
-    Object.keys(literal_keys).forEach(function(name) {
-        var match_func = literal_keys[name];
-        var not_name = "not_" + name;
-        var disallowed_values = self[not_name];
-        if (disallowed_values.map(match_func)) {
+    const self = this;
+    for (let n = 0; n < Object.keys(literal_keys).length; n++) {
+        const name = Object.keys(literal_keys)[n];
+        const match_func = literal_keys[name];
+        const not_name = "not_" + name;
+        const disallowed_values = self[not_name];
+        if (disallowed_values.filter(match_func).length > 0) {
             return false;
         }
 
-        var allowed_values = self[name];
+        const allowed_values = self[name];
         if (allowed_values) {
             if (!allowed_values.map(match_func)) {
                 return false;
             }
         }
-    });
+    }
 
-    var contains_url_filter = this.filter_json.contains_url;
+    const contains_url_filter = this.filter_json.contains_url;
     if (contains_url_filter !== undefined) {
         if (contains_url_filter !== contains_url) {
             return false;
         }
     }
 
     return true;
 };
 
 /**
  * Filters a list of events down to those which match this filter component
  * @param {MatrixEvent[]} events  Events to be checked againt the filter component
  * @return {MatrixEvent[]} events which matched the filter component
  */
-FilterComponent.prototype.filter = function(events) {
+FilterComponent.prototype.filter = function (events) {
     return events.filter(this.check, this);
 };
 
 /**
  * Returns the limit field for a given filter component, providing a default of
  * 10 if none is otherwise specified.  Cargo-culted from Synapse.
  * @return {Number} the limit for this filter component.
  */
-FilterComponent.prototype.limit = function() {
+FilterComponent.prototype.limit = function () {
     return this.filter_json.limit !== undefined ? this.filter_json.limit : 10;
 };
 
 /** The FilterComponent class */
-module.exports = FilterComponent;
+module.exports = FilterComponent;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/filter.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/filter.js
@@ -13,27 +13,27 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY 
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * @module filter
  */
 
-var FilterComponent = require("./filter-component");
+const FilterComponent = require("./filter-component");
 
 /**
  * @param {Object} obj
  * @param {string} keyNesting
  * @param {*} val
  */
 function setProp(obj, keyNesting, val) {
-    var nestedKeys = keyNesting.split(".");
-    var currentObj = obj;
-    for (var i = 0; i < (nestedKeys.length - 1); i++) {
+    const nestedKeys = keyNesting.split(".");
+    let currentObj = obj;
+    for (let i = 0; i < nestedKeys.length - 1; i++) {
         if (!currentObj[nestedKeys[i]]) {
             currentObj[nestedKeys[i]] = {};
         }
         currentObj = currentObj[nestedKeys[i]];
     }
     currentObj[nestedKeys[nestedKeys.length - 1]] = val;
 }
 
@@ -46,37 +46,47 @@ function setProp(obj, keyNesting, val) {
  * @prop {?string} filterId The filter ID
  */
 function Filter(userId, filterId) {
     this.userId = userId;
     this.filterId = filterId;
     this.definition = {};
 }
 
+Filter.LAZY_LOADING_MESSAGES_FILTER = {
+    lazy_load_members: true
+};
+
+Filter.LAZY_LOADING_SYNC_FILTER = {
+    room: {
+        state: Filter.LAZY_LOADING_MESSAGES_FILTER
+    }
+};
+
 /**
  * Get the ID of this filter on your homeserver (if known)
  * @return {?Number} The filter ID
  */
-Filter.prototype.getFilterId = function() {
+Filter.prototype.getFilterId = function () {
     return this.filterId;
 };
 
 /**
  * Get the JSON body of the filter.
  * @return {Object} The filter definition
  */
-Filter.prototype.getDefinition = function() {
+Filter.prototype.getDefinition = function () {
     return this.definition;
 };
 
 /**
  * Set the JSON body of the filter
  * @param {Object} definition The filter definition
  */
-Filter.prototype.setDefinition = function(definition) {
+Filter.prototype.setDefinition = function (definition) {
     this.definition = definition;
 
     // This is all ported from synapse's FilterCollection()
 
     // definitions look something like:
     // {
     //   "room": {
     //     "rooms": ["!abcde:example.com"],
@@ -101,35 +111,33 @@ Filter.prototype.setDefinition = functio
     //   "presence": {
     //     "types": ["m.presence"],
     //     "not_senders": ["@alice:example.com"]
     //   },
     //   "event_format": "client",
     //   "event_fields": ["type", "content", "sender"]
     // }
 
-    var room_filter_json = definition.room;
+    const room_filter_json = definition.room;
 
     // consider the top level rooms/not_rooms filter
-    var room_filter_fields = {};
+    const room_filter_fields = {};
     if (room_filter_json) {
         if (room_filter_json.rooms) {
             room_filter_fields.rooms = room_filter_json.rooms;
         }
         if (room_filter_json.rooms) {
             room_filter_fields.not_rooms = room_filter_json.not_rooms;
         }
 
         this._include_leave = room_filter_json.include_leave || false;
     }
 
     this._room_filter = new FilterComponent(room_filter_fields);
-    this._room_timeline_filter = new FilterComponent(
-        room_filter_json ? (room_filter_json.timeline || {}) : {}
-    );
+    this._room_timeline_filter = new FilterComponent(room_filter_json ? room_filter_json.timeline || {} : {});
 
     // don't bother porting this from synapse yet:
     // this._room_state_filter =
     //     new FilterComponent(room_filter_json.state || {});
     // this._room_ephemeral_filter =
     //     new FilterComponent(room_filter_json.ephemeral || {});
     // this._room_account_data_filter =
     //     new FilterComponent(room_filter_json.account_data || {});
@@ -138,55 +146,55 @@ Filter.prototype.setDefinition = functio
     // this._account_data_filter =
     //     new FilterComponent(definition.account_data || {});
 };
 
 /**
  * Get the room.timeline filter component of the filter
  * @return {FilterComponent} room timeline filter component
  */
-Filter.prototype.getRoomTimelineFilterComponent = function() {
+Filter.prototype.getRoomTimelineFilterComponent = function () {
     return this._room_timeline_filter;
 };
 
 /**
  * Filter the list of events based on whether they are allowed in a timeline
  * based on this filter
  * @param {MatrixEvent[]} events  the list of events being filtered
  * @return {MatrixEvent[]} the list of events which match the filter
  */
-Filter.prototype.filterRoomTimeline = function(events) {
+Filter.prototype.filterRoomTimeline = function (events) {
     return this._room_timeline_filter.filter(this._room_filter.filter(events));
 };
 
 /**
  * Set the max number of events to return for each room's timeline.
  * @param {Number} limit The max number of events to return for each room.
  */
-Filter.prototype.setTimelineLimit = function(limit) {
+Filter.prototype.setTimelineLimit = function (limit) {
     setProp(this.definition, "room.timeline.limit", limit);
 };
 
 /**
  * Control whether left rooms should be included in responses.
  * @param {boolean} includeLeave True to make rooms the user has left appear
  * in responses.
  */
-Filter.prototype.setIncludeLeaveRooms = function(includeLeave) {
+Filter.prototype.setIncludeLeaveRooms = function (includeLeave) {
     setProp(this.definition, "room.include_leave", includeLeave);
 };
 
 /**
  * Create a filter from existing data.
  * @static
  * @param {string} userId
  * @param {string} filterId
  * @param {Object} jsonObj
  * @return {Filter}
  */
-Filter.fromJson = function(userId, filterId, jsonObj) {
-    var filter = new Filter(userId, filterId);
+Filter.fromJson = function (userId, filterId, jsonObj) {
+    const filter = new Filter(userId, filterId);
     filter.setDefinition(jsonObj);
     return filter;
 };
 
 /** The Filter class */
-module.exports = Filter;
+module.exports = Filter;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/http-api.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/http-api.js
@@ -1,10 +1,11 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -13,23 +14,36 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY 
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * This is an internal module. See {@link MatrixHttpApi} for the public class.
  * @module http-api
  */
-var q = require("q");
-var utils = require("./utils");
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../src/logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const parseContentType = require('content-type').parse;
+
+const utils = require("./utils");
+
 
 // we use our own implementation of setTimeout, so that if we get suspended in
 // the middle of a /sync, we cancel the sync as soon as we awake, rather than
 // waiting for the delay to elapse.
-var callbacks = require("./realtime-callbacks");
+const callbacks = require("./realtime-callbacks");
 
 /*
 TODO:
 - CS: complete register function (doing stages)
 - Identity server: linkEmail, authEmail, bindEmail, lookup3pid
 */
 
 /**
@@ -38,200 +52,226 @@ TODO:
 module.exports.PREFIX_R0 = "/_matrix/client/r0";
 
 /**
  * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs.
  */
 module.exports.PREFIX_UNSTABLE = "/_matrix/client/unstable";
 
 /**
- * URI path for the identity API
+ * URI path for v1 of the the identity API
  */
 module.exports.PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1";
 
 /**
+ * URI path for the v2 identity API
+ */
+module.exports.PREFIX_IDENTITY_V2 = "/_matrix/identity/v2";
+
+/**
  * URI path for the media repo API
  */
 module.exports.PREFIX_MEDIA_R0 = "/_matrix/media/r0";
 
 /**
  * Construct a MatrixHttpApi.
  * @constructor
  * @param {EventEmitter} event_emitter The event emitter to use for emitting events
  * @param {Object} opts The options to use for this HTTP API.
  * @param {string} opts.baseUrl Required. The base client-server URL e.g.
  * 'http://localhost:8008'.
  * @param {Function} opts.request Required. The function to call for HTTP
  * requests. This function must look like function(opts, callback){ ... }.
  * @param {string} opts.prefix Required. The matrix client prefix to use, e.g.
  * '/_matrix/client/r0'. See PREFIX_R0 and PREFIX_UNSTABLE for constants.
  *
- * @param {bool=} opts.onlyData True to return only the 'data' component of the
+ * @param {boolean} opts.onlyData True to return only the 'data' component of the
  * response (e.g. the parsed HTTP body). If false, requests will return an
  * object with the properties <tt>code</tt>, <tt>headers</tt> and <tt>data</tt>.
  *
  * @param {string} opts.accessToken The access_token to send with requests. Can be
  * null to not send an access token.
- * @param {Object} opts.extraParams Optional. Extra query parameters to send on
+ * @param {Object=} opts.extraParams Optional. Extra query parameters to send on
  * requests.
+ * @param {Number=} opts.localTimeoutMs The default maximum amount of time to wait
+ * before timing out the request. If not specified, there is no timeout.
+ * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use
+ * Authorization header instead of query param to send the access token to the server.
  */
 module.exports.MatrixHttpApi = function MatrixHttpApi(event_emitter, opts) {
     utils.checkObjectHasKeys(opts, ["baseUrl", "request", "prefix"]);
     opts.onlyData = opts.onlyData || false;
     this.event_emitter = event_emitter;
     this.opts = opts;
+    this.useAuthorizationHeader = Boolean(opts.useAuthorizationHeader);
     this.uploads = [];
 };
 
 module.exports.MatrixHttpApi.prototype = {
+    /**
+     * Sets the baase URL for the identity server
+     * @param {string} url The new base url
+     */
+    setIdBaseUrl: function (url) {
+        this.opts.idBaseUrl = url;
+    },
 
     /**
      * Get the content repository url with query parameters.
      * @return {Object} An object with a 'base', 'path' and 'params' for base URL,
      *          path and query parameters respectively.
      */
-    getContentUri: function() {
-        var params = {
+    getContentUri: function () {
+        const params = {
             access_token: this.opts.accessToken
         };
         return {
             base: this.opts.baseUrl,
-            path: "/_matrix/media/v1/upload",
+            path: "/_matrix/media/r0/upload",
             params: params
         };
     },
 
     /**
      * Upload content to the Home Server
      *
      * @param {object} file The object to upload. On a browser, something that
      *   can be sent to XMLHttpRequest.send (typically a File).  Under node.js,
      *   a Buffer, String or ReadStream.
      *
      * @param {object} opts  options object
      *
      * @param {string=} opts.name   Name to give the file on the server. Defaults
      *   to <tt>file.name</tt>.
      *
+     * @param {boolean=} opts.includeFilename if false will not send the filename,
+     *   e.g for encrypted file uploads where filename leaks are undesirable.
+     *   Defaults to true.
+     *
      * @param {string=} opts.type   Content-type for the upload. Defaults to
      *   <tt>file.type</tt>, or <tt>applicaton/octet-stream</tt>.
      *
      * @param {boolean=} opts.rawResponse Return the raw body, rather than
      *   parsing the JSON. Defaults to false (except on node.js, where it
      *   defaults to true for backwards compatibility).
      *
      * @param {boolean=} opts.onlyContentUri Just return the content URI,
      *   rather than the whole body. Defaults to false (except on browsers,
      *   where it defaults to true for backwards compatibility). Ignored if
      *   opts.rawResponse is true.
      *
      * @param {Function=} opts.callback Deprecated. Optional. The callback to
      *    invoke on success/failure. See the promise return values for more
      *    information.
      *
+     * @param {Function=} opts.progressHandler Optional. Called when a chunk of
+     *    data has been uploaded, with an object containing the fields `loaded`
+     *    (number of bytes transferred) and `total` (total size, if known).
+     *
      * @return {module:client.Promise} Resolves to response object, as
      *    determined by this.opts.onlyData, opts.rawResponse, and
      *    opts.onlyContentUri.  Rejects with an error (usually a MatrixError).
      */
-    uploadContent: function(file, opts) {
+    uploadContent: function (file, opts) {
         if (utils.isFunction(opts)) {
             // opts used to be callback
             opts = {
-                callback: opts,
+                callback: opts
             };
         } else if (opts === undefined) {
             opts = {};
         }
 
+        // default opts.includeFilename to true (ignoring falsey values)
+        const includeFilename = opts.includeFilename !== false;
+
         // if the file doesn't have a mime type, use a default since
         // the HS errors if we don't supply one.
-        var contentType = opts.type || file.type || 'application/octet-stream';
-        var fileName = opts.name || file.name;
+        const contentType = opts.type || file.type || 'application/octet-stream';
+        const fileName = opts.name || file.name;
 
-        // we used to recommend setting file.stream to the thing to upload on
-        // nodejs.
-        var body = file.stream ? file.stream : file;
+        // We used to recommend setting file.stream to the thing to upload on
+        // Node.js. As of 2019-06-11, this is still in widespread use in various
+        // clients, so we should preserve this for simple objects used in
+        // Node.js. File API objects (via either the File or Blob interfaces) in
+        // the browser now define a `stream` method, which leads to trouble
+        // here, so we also check the type of `stream`.
+        let body = file;
+        if (body.stream && typeof body.stream !== "function") {
+            _logger2.default.warn("Using `file.stream` as the content to upload. Future " + "versions of the js-sdk will change this to expect `file` to " + "be the content directly.");
+            body = body.stream;
+        }
 
         // backwards-compatibility hacks where we used to do different things
         // between browser and node.
-        var rawResponse = opts.rawResponse;
+        let rawResponse = opts.rawResponse;
         if (rawResponse === undefined) {
             if (global.XMLHttpRequest) {
                 rawResponse = false;
             } else {
-                console.warn(
-                    "Returning the raw JSON from uploadContent(). Future " +
-                    "versions of the js-sdk will change this default, to " +
-                    "return the parsed object. Set opts.rawResponse=false " +
-                    "to change this behaviour now."
-                );
+                _logger2.default.warn("Returning the raw JSON from uploadContent(). Future " + "versions of the js-sdk will change this default, to " + "return the parsed object. Set opts.rawResponse=false " + "to change this behaviour now.");
                 rawResponse = true;
             }
         }
 
-        var onlyContentUri = opts.onlyContentUri;
+        let onlyContentUri = opts.onlyContentUri;
         if (!rawResponse && onlyContentUri === undefined) {
             if (global.XMLHttpRequest) {
-                console.warn(
-                    "Returning only the content-uri from uploadContent(). " +
-                    "Future versions of the js-sdk will change this " +
-                    "default, to return the whole response object. Set " +
-                    "opts.onlyContentUri=false to change this behaviour now."
-                );
+                _logger2.default.warn("Returning only the content-uri from uploadContent(). " + "Future versions of the js-sdk will change this " + "default, to return the whole response object. Set " + "opts.onlyContentUri=false to change this behaviour now.");
                 onlyContentUri = true;
             } else {
                 onlyContentUri = false;
             }
         }
 
         // browser-request doesn't support File objects because it deep-copies
         // the options using JSON.parse(JSON.stringify(options)). Instead of
         // loading the whole file into memory as a string and letting
         // browser-request base64 encode and then decode it again, we just
         // use XMLHttpRequest directly.
         // (browser-request doesn't support progress either, which is also kind
         // of important here)
 
-        var upload = { loaded: 0, total: 0 };
-        var promise;
+        const upload = { loaded: 0, total: 0 };
+        let promise;
 
         // XMLHttpRequest doesn't parse JSON for us. request normally does, but
         // we're setting opts.json=false so that it doesn't JSON-encode the
         // request, which also means it doesn't JSON-decode the response. Either
         // way, we have to JSON-parse the response ourselves.
-        var bodyParser = null;
+        let bodyParser = null;
         if (!rawResponse) {
-            bodyParser = function(rawBody) {
-                var body = JSON.parse(rawBody);
+            bodyParser = function (rawBody) {
+                let body = JSON.parse(rawBody);
                 if (onlyContentUri) {
                     body = body.content_uri;
                     if (body === undefined) {
                         throw Error('Bad response');
                     }
                 }
                 return body;
             };
         }
 
         if (global.XMLHttpRequest) {
-            var defer = q.defer();
-            var xhr = new global.XMLHttpRequest();
+            const defer = _bluebird2.default.defer();
+            const xhr = new global.XMLHttpRequest();
             upload.xhr = xhr;
-            var cb = requestCallback(defer, opts.callback, this.opts.onlyData);
+            const cb = requestCallback(defer, opts.callback, this.opts.onlyData);
 
-            var timeout_fn = function() {
+            const timeout_fn = function () {
                 xhr.abort();
                 cb(new Error('Timeout'));
             };
 
             // set an initial timeout of 30s; we'll advance it each time we get
             // a progress notification
             xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000);
 
-            xhr.onreadystatechange = function() {
+            xhr.onreadystatechange = function () {
                 switch (xhr.readyState) {
                     case global.XMLHttpRequest.DONE:
                         callbacks.clearTimeout(xhr.timeout_timer);
                         var resp;
                         try {
                             if (!xhr.responseText) {
                                 throw new Error('No response body.');
                             }
@@ -243,169 +283,209 @@ module.exports.MatrixHttpApi.prototype =
                             err.http_status = xhr.status;
                             cb(err);
                             return;
                         }
                         cb(undefined, xhr, resp);
                         break;
                 }
             };
-            xhr.upload.addEventListener("progress", function(ev) {
+            xhr.upload.addEventListener("progress", function (ev) {
                 callbacks.clearTimeout(xhr.timeout_timer);
                 upload.loaded = ev.loaded;
                 upload.total = ev.total;
                 xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000);
-                defer.notify(ev);
+                if (opts.progressHandler) {
+                    opts.progressHandler({
+                        loaded: ev.loaded,
+                        total: ev.total
+                    });
+                }
             });
-            var url = this.opts.baseUrl + "/_matrix/media/v1/upload";
-            url += "?access_token=" + encodeURIComponent(this.opts.accessToken);
-            url += "&filename=" + encodeURIComponent(fileName);
+            let url = this.opts.baseUrl + "/_matrix/media/r0/upload";
+
+            const queryArgs = [];
+
+            if (includeFilename && fileName) {
+                queryArgs.push("filename=" + encodeURIComponent(fileName));
+            }
+
+            if (!this.useAuthorizationHeader) {
+                queryArgs.push("access_token=" + encodeURIComponent(this.opts.accessToken));
+            }
+
+            if (queryArgs.length > 0) {
+                url += "?" + queryArgs.join("&");
+            }
 
             xhr.open("POST", url);
+            if (this.useAuthorizationHeader) {
+                xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken);
+            }
             xhr.setRequestHeader("Content-Type", contentType);
             xhr.send(body);
             promise = defer.promise;
 
             // dirty hack (as per _request) to allow the upload to be cancelled.
             promise.abort = xhr.abort.bind(xhr);
         } else {
-            var queryParams = {
-                filename: fileName,
-            };
+            const queryParams = {};
+
+            if (includeFilename && fileName) {
+                queryParams.filename = fileName;
+            }
 
-            promise = this.authedRequest(
-                opts.callback, "POST", "/upload", queryParams, body, {
-                    prefix: "/_matrix/media/v1",
-                    headers: {"Content-Type": contentType},
-                    json: false,
-                    bodyParser: bodyParser,
-                }
-            );
+            promise = this.authedRequest(opts.callback, "POST", "/upload", queryParams, body, {
+                prefix: "/_matrix/media/r0",
+                headers: { "Content-Type": contentType },
+                json: false,
+                bodyParser: bodyParser
+            });
         }
 
-        var self = this;
+        const self = this;
 
         // remove the upload from the list on completion
-        var promise0 = promise.finally(function() {
-            for (var i = 0; i < self.uploads.length; ++i) {
+        const promise0 = promise.finally(function () {
+            for (let i = 0; i < self.uploads.length; ++i) {
                 if (self.uploads[i] === upload) {
                     self.uploads.splice(i, 1);
                     return;
                 }
             }
         });
 
         // copy our dirty abort() method to the new promise
         promise0.abort = promise.abort;
 
         upload.promise = promise0;
         this.uploads.push(upload);
 
         return promise0;
     },
 
-    cancelUpload: function(promise) {
+    cancelUpload: function (promise) {
         if (promise.abort) {
             promise.abort();
             return true;
         }
         return false;
     },
 
-    getCurrentUploads: function() {
+    getCurrentUploads: function () {
         return this.uploads;
     },
 
-    idServerRequest: function(callback, method, path, params, prefix) {
-        var fullUri = this.opts.idBaseUrl + prefix + path;
+    idServerRequest: function (callback, method, path, params, prefix, accessToken) {
+        if (!this.opts.idBaseUrl) {
+            throw new Error("No Identity Server base URL set");
+        }
+
+        const fullUri = this.opts.idBaseUrl + prefix + path;
 
         if (callback !== undefined && !utils.isFunction(callback)) {
-            throw Error(
-                "Expected callback to be a function but got " + typeof callback
-            );
+            throw Error("Expected callback to be a function but got " + typeof callback);
         }
 
-        var opts = {
+        const opts = {
             uri: fullUri,
             method: method,
             withCredentials: false,
-            json: false,
-            _matrix_opts: this.opts
+            json: true, // we want a JSON response if we can
+            _matrix_opts: this.opts,
+            headers: {}
         };
-        if (method == 'GET') {
+        if (method === 'GET') {
             opts.qs = params;
-        } else {
-            opts.form = params;
+        } else if (typeof params === "object") {
+            opts.json = params;
+        }
+        if (accessToken) {
+            opts.headers['Authorization'] = `Bearer ${accessToken}`;
         }
 
-        var defer = q.defer();
-        this.opts.request(
-            opts,
-            requestCallback(defer, callback, this.opts.onlyData)
-        );
-        // ID server does not always take JSON, so we can't use requests' 'json'
-        // option as we do with the home server, but it does return JSON, so
-        // parse it manually
-        return defer.promise.then(function(response) {
-            return JSON.parse(response);
-        });
+        const defer = _bluebird2.default.defer();
+        this.opts.request(opts, requestCallback(defer, callback, this.opts.onlyData));
+        return defer.promise;
     },
 
     /**
      * Perform an authorised request to the homeserver.
      * @param {Function} callback Optional. The callback to invoke on
      * success/failure. See the promise return values for more information.
      * @param {string} method The HTTP method e.g. "GET".
      * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
      * "/createRoom".
      *
      * @param {Object=} queryParams A dict of query params (these will NOT be
      * urlencoded). If unspecified, there will be no query params.
      *
      * @param {Object} data The HTTP JSON body.
      *
-     * @param {Object=} opts additional options
+     * @param {Object|Number=} opts additional options. If a number is specified,
+     * this is treated as `opts.localTimeoutMs`.
      *
      * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before
      * timing out the request. If not specified, there is no timeout.
      *
      * @param {sting=} opts.prefix The full prefix to use e.g.
      * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
      *
      * @param {Object=} opts.headers map of additional request headers
      *
      * @return {module:client.Promise} Resolves to <code>{data: {Object},
      * headers: {Object}, code: {Number}}</code>.
      * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
      * object only.
      * @return {module:http-api.MatrixError} Rejects with an error if a problem
      * occurred. This includes network problems and Matrix-specific error JSON.
      */
-    authedRequest: function(callback, method, path, queryParams, data, opts) {
+    authedRequest: function (callback, method, path, queryParams, data, opts) {
         if (!queryParams) {
             queryParams = {};
         }
-        if (!queryParams.access_token) {
-            queryParams.access_token = this.opts.accessToken;
+        if (this.useAuthorizationHeader) {
+            if (isFinite(opts)) {
+                // opts used to be localTimeoutMs
+                opts = {
+                    localTimeoutMs: opts
+                };
+            }
+            if (!opts) {
+                opts = {};
+            }
+            if (!opts.headers) {
+                opts.headers = {};
+            }
+            if (!opts.headers.Authorization) {
+                opts.headers.Authorization = "Bearer " + this.opts.accessToken;
+            }
+            if (queryParams.access_token) {
+                delete queryParams.access_token;
+            }
+        } else {
+            if (!queryParams.access_token) {
+                queryParams.access_token = this.opts.accessToken;
+            }
         }
 
-        var request_promise = this.request(
-            callback, method, path, queryParams, data, opts
-        );
+        const requestPromise = this.request(callback, method, path, queryParams, data, opts);
 
-        var self = this;
-        request_promise.catch(function(err) {
+        const self = this;
+        requestPromise.catch(function (err) {
             if (err.errcode == 'M_UNKNOWN_TOKEN') {
-                self.event_emitter.emit("Session.logged_out");
+                self.event_emitter.emit("Session.logged_out", err);
+            } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') {
+                self.event_emitter.emit("no_consent", err.message, err.data.consent_uri);
             }
         });
 
         // return the original promise, otherwise tests break due to it having to
         // go around the event loop one more time to process the result of the request
-        return request_promise;
+        return requestPromise;
     },
 
     /**
      * Perform a request to the homeserver without any credentials.
      * @param {Function} callback Optional. The callback to invoke on
      * success/failure. See the promise return values for more information.
      * @param {string} method The HTTP method e.g. "GET".
      * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
@@ -428,94 +508,22 @@ module.exports.MatrixHttpApi.prototype =
      *
      * @return {module:client.Promise} Resolves to <code>{data: {Object},
      * headers: {Object}, code: {Number}}</code>.
      * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
      * object only.
      * @return {module:http-api.MatrixError} Rejects with an error if a problem
      * occurred. This includes network problems and Matrix-specific error JSON.
      */
-    request: function(callback, method, path, queryParams, data, opts) {
+    request: function (callback, method, path, queryParams, data, opts) {
         opts = opts || {};
-        var prefix = opts.prefix !== undefined ? opts.prefix : this.opts.prefix;
-        var fullUri = this.opts.baseUrl + prefix + path;
-
-        return this.requestOtherUrl(
-            callback, method, fullUri, queryParams, data, opts
-        );
-    },
+        const prefix = opts.prefix !== undefined ? opts.prefix : this.opts.prefix;
+        const fullUri = this.opts.baseUrl + prefix + path;
 
-    /**
-     * Perform an authorised request to the homeserver with a specific path
-     * prefix which overrides the default for this call only. Useful for hitting
-     * different Matrix Client-Server versions.
-     * @param {Function} callback Optional. The callback to invoke on
-     * success/failure. See the promise return values for more information.
-     * @param {string} method The HTTP method e.g. "GET".
-     * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
-     * "/createRoom".
-     * @param {Object} queryParams A dict of query params (these will NOT be
-     * urlencoded).
-     * @param {Object} data The HTTP JSON body.
-     * @param {string} prefix The full prefix to use e.g.
-     * "/_matrix/client/v2_alpha".
-     * @param {Number=} localTimeoutMs The maximum amount of time to wait before
-     * timing out the request. If not specified, there is no timeout.
-     * @return {module:client.Promise} Resolves to <code>{data: {Object},
-     * headers: {Object}, code: {Number}}</code>.
-     * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
-     * object only.
-     * @return {module:http-api.MatrixError} Rejects with an error if a problem
-     * occurred. This includes network problems and Matrix-specific error JSON.
-     *
-     * @deprecated prefer authedRequest with opts.prefix
-     */
-    authedRequestWithPrefix: function(callback, method, path, queryParams, data,
-                                      prefix, localTimeoutMs) {
-        return this.authedRequest(
-            callback, method, path, queryParams, data, {
-                localTimeoutMs: localTimeoutMs,
-                prefix: prefix,
-            }
-        );
-    },
-
-    /**
-     * Perform a request to the homeserver without any credentials but with a
-     * specific path prefix which overrides the default for this call only.
-     * Useful for hitting different Matrix Client-Server versions.
-     * @param {Function} callback Optional. The callback to invoke on
-     * success/failure. See the promise return values for more information.
-     * @param {string} method The HTTP method e.g. "GET".
-     * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
-     * "/createRoom".
-     * @param {Object} queryParams A dict of query params (these will NOT be
-     * urlencoded).
-     * @param {Object} data The HTTP JSON body.
-     * @param {string} prefix The full prefix to use e.g.
-     * "/_matrix/client/v2_alpha".
-     * @param {Number=} localTimeoutMs The maximum amount of time to wait before
-     * timing out the request. If not specified, there is no timeout.
-     * @return {module:client.Promise} Resolves to <code>{data: {Object},
-     * headers: {Object}, code: {Number}}</code>.
-     * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
-     * object only.
-     * @return {module:http-api.MatrixError} Rejects with an error if a problem
-     * occurred. This includes network problems and Matrix-specific error JSON.
-     *
-     * @deprecated prefer request with opts.prefix
-     */
-    requestWithPrefix: function(callback, method, path, queryParams, data, prefix,
-                                localTimeoutMs) {
-        return this.request(
-            callback, method, path, queryParams, data, {
-                localTimeoutMs: localTimeoutMs,
-                prefix: prefix,
-            }
-        );
+        return this.requestOtherUrl(callback, method, fullUri, queryParams, data, opts);
     },
 
     /**
      * Perform a request to an arbitrary URL.
      * @param {Function} callback Optional. The callback to invoke on
      * success/failure. See the promise return values for more information.
      * @param {string} method The HTTP method e.g. "GET".
      * @param {string} uri The HTTP URI
@@ -537,45 +545,42 @@ module.exports.MatrixHttpApi.prototype =
      *
      * @return {module:client.Promise} Resolves to <code>{data: {Object},
      * headers: {Object}, code: {Number}}</code>.
      * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
      * object only.
      * @return {module:http-api.MatrixError} Rejects with an error if a problem
      * occurred. This includes network problems and Matrix-specific error JSON.
      */
-    requestOtherUrl: function(callback, method, uri, queryParams, data,
-                              opts) {
+    requestOtherUrl: function (callback, method, uri, queryParams, data, opts) {
         if (opts === undefined || opts === null) {
             opts = {};
         } else if (isFinite(opts)) {
             // opts used to be localTimeoutMs
             opts = {
                 localTimeoutMs: opts
             };
         }
 
-        return this._request(
-            callback, method, uri, queryParams, data, opts
-        );
+        return this._request(callback, method, uri, queryParams, data, opts);
     },
 
     /**
      * Form and return a homeserver request URL based on the given path
      * params and prefix.
      * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
      * "/createRoom".
      * @param {Object} queryParams A dict of query params (these will NOT be
      * urlencoded).
      * @param {string} prefix The full prefix to use e.g.
      * "/_matrix/client/v2_alpha".
      * @return {string} URL
      */
-    getUrl: function(path, queryParams, prefix) {
-        var queryString = "";
+    getUrl: function (path, queryParams, prefix) {
+        let queryString = "";
         if (queryParams) {
             queryString = "?" + utils.encodeParams(queryParams);
         }
         return this.opts.baseUrl + prefix + path + queryString;
     },
 
     /**
      * @private
@@ -589,167 +594,257 @@ module.exports.MatrixHttpApi.prototype =
      *
      * @param {boolean} [opts.json =true] Json-encode data before sending, and
      *   decode response on receipt. (We will still json-decode error
      *   responses, even if this is false.)
      *
      * @param {object=} opts.headers  extra request headers
      *
      * @param {number=} opts.localTimeoutMs client-side timeout for the
-     *    request. No timeout if undefined.
+     *    request. Default timeout if falsy.
      *
      * @param {function=} opts.bodyParser function to parse the body of the
      *    response before passing it to the promise and callback.
      *
      * @return {module:client.Promise} a promise which resolves to either the
      * response object (if this.opts.onlyData is truthy), or the parsed
      * body. Rejects
      */
-    _request: function(callback, method, uri, queryParams, data, opts) {
+    _request: function (callback, method, uri, queryParams, data, opts) {
         if (callback !== undefined && !utils.isFunction(callback)) {
-            throw Error(
-                "Expected callback to be a function but got " + typeof callback
-            );
+            throw Error("Expected callback to be a function but got " + typeof callback);
         }
         opts = opts || {};
 
-        var self = this;
+        const self = this;
         if (this.opts.extraParams) {
-            for (var key in this.opts.extraParams) {
-                if (!this.opts.extraParams.hasOwnProperty(key)) { continue; }
+            for (const key in this.opts.extraParams) {
+                if (!this.opts.extraParams.hasOwnProperty(key)) {
+                    continue;
+                }
                 queryParams[key] = this.opts.extraParams[key];
             }
         }
 
-        var json = opts.json === undefined ? true : opts.json;
+        const headers = utils.extend({}, opts.headers || {});
+        const json = opts.json === undefined ? true : opts.json;
+        let bodyParser = opts.bodyParser;
 
-        var defer = q.defer();
+        // we handle the json encoding/decoding here, because request and
+        // browser-request make a mess of it. Specifically, they attempt to
+        // json-decode plain-text error responses, which in turn means that the
+        // actual error gets swallowed by a SyntaxError.
 
-        var timeoutId;
-        var timedOut = false;
-        var req;
-        var localTimeoutMs = opts.localTimeoutMs;
-        if (localTimeoutMs) {
-            timeoutId = callbacks.setTimeout(function() {
-                timedOut = true;
-                if (req && req.abort) {
-                    req.abort();
-                }
-                defer.reject(new module.exports.MatrixError({
-                    error: "Locally timed out waiting for a response",
-                    errcode: "ORG.MATRIX.JSSDK_TIMEOUT",
-                    timeout: localTimeoutMs
-                }));
-            }, localTimeoutMs);
+        if (json) {
+            if (data) {
+                data = JSON.stringify(data);
+                headers['content-type'] = 'application/json';
+            }
+
+            if (!headers['accept']) {
+                headers['accept'] = 'application/json';
+            }
+
+            if (bodyParser === undefined) {
+                bodyParser = function (rawBody) {
+                    return JSON.parse(rawBody);
+                };
+            }
         }
 
-        var reqPromise = defer.promise;
+        const defer = _bluebird2.default.defer();
+
+        let timeoutId;
+        let timedOut = false;
+        let req;
+        const localTimeoutMs = opts.localTimeoutMs || this.opts.localTimeoutMs;
+
+        const resetTimeout = () => {
+            if (localTimeoutMs) {
+                if (timeoutId) {
+                    callbacks.clearTimeout(timeoutId);
+                }
+                timeoutId = callbacks.setTimeout(function () {
+                    timedOut = true;
+                    if (req && req.abort) {
+                        req.abort();
+                    }
+                    defer.reject(new module.exports.MatrixError({
+                        error: "Locally timed out waiting for a response",
+                        errcode: "ORG.MATRIX.JSSDK_TIMEOUT",
+                        timeout: localTimeoutMs
+                    }));
+                }, localTimeoutMs);
+            }
+        };
+        resetTimeout();
+
+        const reqPromise = defer.promise;
 
         try {
-            req = this.opts.request(
-                {
-                    uri: uri,
-                    method: method,
-                    withCredentials: false,
-                    qs: queryParams,
-                    body: data,
-                    json: json,
-                    timeout: localTimeoutMs,
-                    headers: opts.headers || {},
-                    _matrix_opts: this.opts
-                },
-                function(err, response, body) {
-                    if (localTimeoutMs) {
-                        callbacks.clearTimeout(timeoutId);
-                        if (timedOut) {
-                            return; // already rejected promise
-                        }
+            req = this.opts.request({
+                uri: uri,
+                method: method,
+                withCredentials: false,
+                qs: queryParams,
+                qsStringifyOptions: opts.qsStringifyOptions,
+                useQuerystring: true,
+                body: data,
+                json: false,
+                timeout: localTimeoutMs,
+                headers: headers || {},
+                _matrix_opts: this.opts
+            }, function (err, response, body) {
+                if (localTimeoutMs) {
+                    callbacks.clearTimeout(timeoutId);
+                    if (timedOut) {
+                        return; // already rejected promise
                     }
+                }
 
-                    // if json is falsy, we won't parse any error response, so need
-                    // to do so before turning it into a MatrixError
-                    var parseErrorJson = !json;
-                    var handlerFn = requestCallback(
-                        defer, callback, self.opts.onlyData,
-                        parseErrorJson,
-                        opts.bodyParser
-                    );
-                    handlerFn(err, response, body);
+                const handlerFn = requestCallback(defer, callback, self.opts.onlyData, bodyParser);
+                handlerFn(err, response, body);
+            });
+            if (req) {
+                // This will only work in a browser, where opts.request is the
+                // `browser-request` import. Currently `request` does not support progress
+                // updates - see https://github.com/request/request/pull/2346.
+                // `browser-request` returns an XHRHttpRequest which exposes `onprogress`
+                if ('onprogress' in req) {
+                    req.onprogress = e => {
+                        // Prevent the timeout from rejecting the deferred promise if progress is
+                        // seen with the request
+                        resetTimeout();
+                    };
                 }
-            );
-            if (req && req.abort) {
+
                 // FIXME: This is EVIL, but I can't think of a better way to expose
                 // abort() operations on underlying HTTP requests :(
-                reqPromise.abort = req.abort.bind(req);
+                if (req.abort) reqPromise.abort = req.abort.bind(req);
             }
-        }
-        catch (ex) {
+        } catch (ex) {
             defer.reject(ex);
             if (callback) {
                 callback(ex);
             }
         }
         return reqPromise;
     }
 };
 
 /*
  * Returns a callback that can be invoked by an HTTP request on completion,
  * that will either resolve or reject the given defer as well as invoke the
  * given userDefinedCallback (if any).
  *
+ * HTTP errors are transformed into javascript errors and the deferred is rejected.
+ *
+ * If bodyParser is given, it is used to transform the body of the successful
+ * responses before passing to the defer/callback.
+ *
  * If onlyData is true, the defer/callback is invoked with the body of the
- * response, otherwise the result code.
- *
- * If parseErrorJson is true, we will JSON.parse the body if we get a 4xx error.
+ * response, otherwise the result object (with `code` and `data` fields)
  *
  */
-var requestCallback = function(
-    defer, userDefinedCallback, onlyData,
-    parseErrorJson, bodyParser
-) {
-    userDefinedCallback = userDefinedCallback || function() {};
+const requestCallback = function (defer, userDefinedCallback, onlyData, bodyParser) {
+    userDefinedCallback = userDefinedCallback || function () {};
 
-    return function(err, response, body) {
+    return function (err, response, body) {
         if (!err) {
             try {
                 if (response.statusCode >= 400) {
-                    if (parseErrorJson) {
-                        // we won't have json-decoded the response.
-                        body = JSON.parse(body);
-                    }
-                    err = new module.exports.MatrixError(body);
+                    err = parseErrorResponse(response, body);
                 } else if (bodyParser) {
                     body = bodyParser(body);
                 }
             } catch (e) {
-                err = e;
-            }
-            if (err) {
-                err.httpStatus = response.statusCode;
+                err = new Error(`Error parsing server response: ${e}`);
             }
         }
 
         if (err) {
             defer.reject(err);
             userDefinedCallback(err);
-        }
-        else {
-            var res = {
+        } else {
+            const res = {
                 code: response.statusCode,
+
+                // XXX: why do we bother with this? it doesn't work for
+                // XMLHttpRequest, so clearly we don't use it.
                 headers: response.headers,
                 data: body
             };
             defer.resolve(onlyData ? body : res);
             userDefinedCallback(null, onlyData ? body : res);
         }
     };
 };
 
 /**
+ * Attempt to turn an HTTP error response into a Javascript Error.
+ *
+ * If it is a JSON response, we will parse it into a MatrixError. Otherwise
+ * we return a generic Error.
+ *
+ * @param {XMLHttpRequest|http.IncomingMessage} response response object
+ * @param {String} body raw body of the response
+ * @returns {Error}
+ */
+function parseErrorResponse(response, body) {
+    const httpStatus = response.statusCode;
+    const contentType = getResponseContentType(response);
+
+    let err;
+    if (contentType) {
+        if (contentType.type === 'application/json') {
+            const jsonBody = typeof body === 'object' ? body : JSON.parse(body);
+            err = new module.exports.MatrixError(jsonBody);
+        } else if (contentType.type === 'text/plain') {
+            err = new Error(`Server returned ${httpStatus} error: ${body}`);
+        }
+    }
+
+    if (!err) {
+        err = new Error(`Server returned ${httpStatus} error`);
+    }
+    err.httpStatus = httpStatus;
+    return err;
+}
+
+/**
+ * extract the Content-Type header from the response object, and
+ * parse it to a `{type, parameters}` object.
+ *
+ * returns null if no content-type header could be found.
+ *
+ * @param {XMLHttpRequest|http.IncomingMessage} response response object
+ * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found
+ */
+function getResponseContentType(response) {
+    let contentType;
+    if (response.getResponseHeader) {
+        // XMLHttpRequest provides getResponseHeader
+        contentType = response.getResponseHeader("Content-Type");
+    } else if (response.headers) {
+        // request provides http.IncomingMessage which has a message.headers map
+        contentType = response.headers['content-type'] || null;
+    }
+
+    if (!contentType) {
+        return null;
+    }
+
+    try {
+        return parseContentType(contentType);
+    } catch (e) {
+        throw new Error(`Error parsing Content-Type '${contentType}': ${e}`);
+    }
+}
+
+/**
  * Construct a Matrix error. This is a JavaScript Error with additional
  * information specific to the standard Matrix error response.
  * @constructor
  * @param {Object} errorJson The Matrix error JSON returned from the homeserver.
  * @prop {string} errcode The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
  * @prop {string} name Same as MatrixError.errcode but with a default unknown string.
  * @prop {string} message The Matrix 'error' value, e.g. "Missing token."
  * @prop {Object} data The raw Matrix error JSON used to construct this object.
@@ -759,9 +854,9 @@ module.exports.MatrixError = function Ma
     errorJson = errorJson || {};
     this.errcode = errorJson.errcode;
     this.name = errorJson.errcode || "Unknown error code";
     this.message = errorJson.error || "Unknown message";
     this.data = errorJson;
 };
 module.exports.MatrixError.prototype = Object.create(Error.prototype);
 /** */
-module.exports.MatrixError.prototype.constructor = module.exports.MatrixError;
+module.exports.MatrixError.prototype.constructor = module.exports.MatrixError;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/indexeddb-helpers.js
@@ -0,0 +1,61 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.exists = exists;
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * Check if an IndexedDB database exists. The only way to do so is to try opening it, so
+ * we do that and then delete it did not exist before.
+ *
+ * @param {Object} indexedDB The `indexedDB` interface
+ * @param {string} dbName The database name to test for
+ * @returns {boolean} Whether the database exists
+ */
+function exists(indexedDB, dbName) {
+    return new _bluebird2.default((resolve, reject) => {
+        let exists = true;
+        const req = indexedDB.open(dbName);
+        req.onupgradeneeded = () => {
+            // Since we did not provide an explicit version when opening, this event
+            // should only fire if the DB did not exist before at any version.
+            exists = false;
+        };
+        req.onblocked = () => reject();
+        req.onsuccess = () => {
+            const db = req.result;
+            db.close();
+            if (!exists) {
+                // The DB did not exist before, but has been created as part of this
+                // existence check. Delete it now to restore previous state. Delete can
+                // actually take a while to complete in some browsers, so don't wait for
+                // it. This won't block future open calls that a store might issue next to
+                // properly set up the DB.
+                indexedDB.deleteDatabase(dbName);
+            }
+            resolve(exists);
+        };
+        req.onerror = ev => reject(ev.target.error);
+    });
+} /*
+  Copyright 2019 New Vector Ltd
+  
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+  
+      http://www.apache.org/licenses/LICENSE-2.0
+  
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  */
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/indexeddb-worker.js
@@ -0,0 +1,25 @@
+"use strict";
+
+/*
+Copyright 2017 Vector Creations Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Separate exports file for the indexeddb web worker, which is designed
+ * to be used separately
+ */
+
+/** The {@link module:indexeddb-store-worker~IndexedDBStoreWorker} class. */
+module.exports.IndexedDBStoreWorker = require("./store/indexeddb-store-worker.js");
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/interactive-auth.js
@@ -1,29 +1,46 @@
 /*
 Copyright 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 
 /** @module interactive-auth */
-var q = require("q");
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require("../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
 
-var utils = require("./utils");
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const url = require("url");
+
+const utils = require("./utils");
+
+
+const EMAIL_STAGE_TYPE = "m.login.email.identity";
+const MSISDN_STAGE_TYPE = "m.login.msisdn";
 
 /**
  * Abstracts the logic used to drive the interactive auth process.
  *
  * <p>Components implementing an interactive auth flow should instantiate one of
  * these, passing in the necessary callbacks to the constructor. They should
  * then call attemptAuth, which will return a promise which will resolve or
  * reject when the interactive-auth process completes.
@@ -32,197 +49,460 @@ var utils = require("./utils");
  * callbacks, and information gathered from the user can be submitted with
  * submitAuthDict.
  *
  * @constructor
  * @alias module:interactive-auth
  *
  * @param {object} opts  options object
  *
+ * @param {object} opts.matrixClient A matrix client to use for the auth process
+ *
  * @param {object?} opts.authData error response from the last request. If
  *    null, a request will be made with no auth before starting.
  *
  * @param {function(object?): module:client.Promise} opts.doRequest
- *     called with the new auth dict to submit the request. Should return a
- *     promise which resolves to the successful response or rejects with a
- *     MatrixError.
+ *     called with the new auth dict to submit the request. Also passes a
+ *     second deprecated arg which is a flag set to true if this request
+ *     is a background request. The busyChanged callback should be used
+ *     instead of the backfround flag. Should return a promise which resolves
+ *     to the successful response or rejects with a MatrixError.
+ *
+ * @param {function(bool): module:client.Promise} opts.busyChanged
+ *     called whenever the interactive auth logic becomes busy submitting
+ *     information provided by the user or finsihes. After this has been
+ *     called with true the UI should indicate that a request is in progress
+ *     until it is called again with false.
+ *
+ * @param {function(string, object?)} opts.stateUpdated
+ *     called when the status of the UI auth changes, ie. when the state of
+ *     an auth stage changes of when the auth flow moves to a new stage.
+ *     The arguments are: the login type (eg m.login.password); and an object
+ *     which is either an error or an informational object specific to the
+ *     login type. If the 'errcode' key is defined, the object is an error,
+ *     and has keys:
+ *         errcode: string, the textual error code, eg. M_UNKNOWN
+ *         error: string, human readable string describing the error
+ *
+ *     The login type specific objects are as follows:
+ *         m.login.email.identity:
+ *          * emailSid: string, the sid of the active email auth session
  *
- * @param {function(string, object?)} opts.startAuthStage
- *     called to ask the UI to start a particular auth stage. The arguments
- *     are: the login type (eg m.login.password); and (if the last request
- *     returned an error), an error object, with fields 'errcode' and 'error'.
+ * @param {object?} opts.inputs Inputs provided by the user and used by different
+ *     stages of the auto process. The inputs provided will affect what flow is chosen.
+ *
+ * @param {string?} opts.inputs.emailAddress An email address. If supplied, a flow
+ *     using email verification will be chosen.
+ *
+ * @param {string?} opts.inputs.phoneCountry An ISO two letter country code. Gives
+ *     the country that opts.phoneNumber should be resolved relative to.
+ *
+ * @param {string?} opts.inputs.phoneNumber A phone number. If supplied, a flow
+ *     using phone number validation will be chosen.
+ *
+ * @param {string?} opts.sessionId If resuming an existing interactive auth session,
+ *     the sessionId of that session.
+ *
+ * @param {string?} opts.clientSecret If resuming an existing interactive auth session,
+ *     the client secret for that session
+ *
+ * @param {string?} opts.emailSid If returning from having completed m.login.email.identity
+ *     auth, the sid for the email verification session.
+ *
+ * @param {function?} opts.requestEmailToken A function that takes the email address (string),
+ *     clientSecret (string), attempt number (int) and sessionId (string) and calls the
+ *     relevant requestToken function and returns the promise returned by that function.
+ *     If the resulting promise rejects, the rejection will propagate through to the
+ *     attemptAuth promise.
  *
  */
 function InteractiveAuth(opts) {
-    this._data = opts.authData;
+    this._matrixClient = opts.matrixClient;
+    this._data = opts.authData || {};
     this._requestCallback = opts.doRequest;
-    this._startAuthStageCallback = opts.startAuthStage;
-    this._completionDeferred = null;
+    this._busyChangedCallback = opts.busyChanged;
+    // startAuthStage included for backwards compat
+    this._stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage;
+    this._resolveFunc = null;
+    this._rejectFunc = null;
+    this._inputs = opts.inputs || {};
+    this._requestEmailTokenCallback = opts.requestEmailToken;
+
+    if (opts.sessionId) this._data.session = opts.sessionId;
+    this._clientSecret = opts.clientSecret || this._matrixClient.generateClientSecret();
+    this._emailSid = opts.emailSid;
+    if (this._emailSid === undefined) this._emailSid = null;
+    this._requestingEmailToken = false;
+
+    this._chosenFlow = null;
+    this._currentStage = null;
+
+    // if we are currently trying to submit an auth dict (which includes polling)
+    // the promise the will resolve/reject when it completes
+    this._submitPromise = null;
 }
 
 InteractiveAuth.prototype = {
     /**
      * begin the authentication process.
      *
-     * @return {module:client.Promise}  which resolves to the response on success,
-     * or rejects with the error on failure.
+     * @return {module:client.Promise} which resolves to the response on success,
+     * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if
+     *     no suitable authentication flow can be found
      */
-    attemptAuth: function() {
-        this._completionDeferred = q.defer();
+    attemptAuth: function () {
+        // This promise will be quite long-lived and will resolve when the
+        // request is authenticated and completes successfully.
+        return new _bluebird2.default((resolve, reject) => {
+            this._resolveFunc = resolve;
+            this._rejectFunc = reject;
+
+            // if we have no flows, try a request (we'll have
+            // just a session ID in _data if resuming)
+            if (!this._data.flows) {
+                if (this._busyChangedCallback) this._busyChangedCallback(true);
+                this._doRequest(this._data).finally(() => {
+                    if (this._busyChangedCallback) this._busyChangedCallback(false);
+                });
+            } else {
+                this._startNextAuthStage();
+            }
+        });
+    },
 
-        if (!this._data) {
-            this._doRequest(null);
-        } else {
-            this._startNextAuthStage();
+    /**
+     * Poll to check if the auth session or current stage has been
+     * completed out-of-band. If so, the attemptAuth promise will
+     * be resolved.
+     */
+    poll: async function () {
+        if (!this._data.session) return;
+        // if we currently have a request in flight, there's no point making
+        // another just to check what the status is
+        if (this._submitPromise) return;
+
+        let authDict = {};
+        if (this._currentStage == EMAIL_STAGE_TYPE) {
+            // The email can be validated out-of-band, but we need to provide the
+            // creds so the HS can go & check it.
+            if (this._emailSid) {
+                const creds = {
+                    sid: this._emailSid,
+                    client_secret: this._clientSecret
+                };
+                if (await this._matrixClient.doesServerRequireIdServerParam()) {
+                    const idServerParsedUrl = url.parse(this._matrixClient.getIdentityServerUrl());
+                    creds.id_server = idServerParsedUrl.host;
+                }
+                authDict = {
+                    type: EMAIL_STAGE_TYPE,
+                    threepid_creds: creds
+                };
+            }
         }
 
-        return this._completionDeferred.promise;
+        this.submitAuthDict(authDict, true);
     },
 
     /**
      * get the auth session ID
      *
      * @return {string} session id
      */
-    getSessionId: function() {
+    getSessionId: function () {
         return this._data ? this._data.session : undefined;
     },
 
     /**
+     * get the client secret used for validation sessions
+     * with the ID server.
+     *
+     * @return {string} client secret
+     */
+    getClientSecret: function () {
+        return this._clientSecret;
+    },
+
+    /**
      * get the server params for a given stage
      *
-     * @param {string}  login type for the stage
-     * @return {object?}  any parameters from the server for this stage
+     * @param {string} loginType login type for the stage
+     * @return {object?} any parameters from the server for this stage
      */
-    getStageParams: function(loginType) {
-        var params = {};
+    getStageParams: function (loginType) {
+        let params = {};
         if (this._data && this._data.params) {
             params = this._data.params;
         }
         return params[loginType];
     },
 
+    getChosenFlow() {
+        return this._chosenFlow;
+    },
+
     /**
      * submit a new auth dict and fire off the request. This will either
      * make attemptAuth resolve/reject, or cause the startAuthStage callback
      * to be called for a new stage.
      *
      * @param {object} authData new auth dict to send to the server. Should
      *    include a `type` propterty denoting the login type, as well as any
      *    other params for that stage.
+     * @param {bool} background If true, this request failing will not result
+     *    in the attemptAuth promise being rejected. This can be set to true
+     *    for requests that just poll to see if auth has been completed elsewhere.
      */
-    submitAuthDict: function(authData) {
-        if (!this._completionDeferred) {
+    submitAuthDict: async function (authData, background) {
+        if (!this._resolveFunc) {
             throw new Error("submitAuthDict() called before attemptAuth()");
         }
 
+        if (!background && this._busyChangedCallback) {
+            this._busyChangedCallback(true);
+        }
+
+        // if we're currently trying a request, wait for it to finish
+        // as otherwise we can get multiple 200 responses which can mean
+        // things like multiple logins for register requests.
+        // (but discard any expections as we only care when its done,
+        // not whether it worked or not)
+        while (this._submitPromise) {
+            try {
+                await this._submitPromise;
+            } catch (e) {}
+        }
+
         // use the sessionid from the last request.
-        var auth = {
-            session: this._data.session,
+        const auth = {
+            session: this._data.session
         };
         utils.extend(auth, authData);
 
-        this._doRequest(auth);
+        try {
+            // NB. the 'background' flag is deprecated by the busyChanged
+            // callback and is here for backwards compat
+            this._submitPromise = this._doRequest(auth, background);
+            await this._submitPromise;
+        } finally {
+            this._submitPromise = null;
+            if (!background && this._busyChangedCallback) {
+                this._busyChangedCallback(false);
+            }
+        }
+    },
+
+    /**
+     * Gets the sid for the email validation session
+     * Specific to m.login.email.identity
+     *
+     * @returns {string} The sid of the email auth session
+     */
+    getEmailSid: function () {
+        return this._emailSid;
+    },
+
+    /**
+     * Sets the sid for the email validation session
+     * This must be set in order to successfully poll for completion
+     * of the email validation.
+     * Specific to m.login.email.identity
+     *
+     * @param {string} sid The sid for the email validation session
+     */
+    setEmailSid: function (sid) {
+        this._emailSid = sid;
     },
 
     /**
      * Fire off a request, and either resolve the promise, or call
      * startAuthStage.
      *
      * @private
      * @param {object?} auth new auth dict, including session id
+     * @param {bool?} background If true, this request is a background poll, so it
+     *    failing will not result in the attemptAuth promise being rejected.
+     *    This can be set to true for requests that just poll to see if auth has
+     *    been completed elsewhere.
      */
-    _doRequest: function(auth) {
-        var self = this;
-
-        // hackery to make sure that synchronous exceptions end up in the catch
-        // handler (without the additional event loop entailed by q.fcall or an
-        // extra q().then)
-        var prom;
+    _doRequest: async function (auth, background) {
         try {
-            prom = this._requestCallback(auth);
-        } catch (e) {
-            prom = q.reject(e);
-        }
+            const result = await this._requestCallback(auth, background);
+            this._resolveFunc(result);
+        } catch (error) {
+            // sometimes UI auth errors don't come with flows
+            const errorFlows = error.data ? error.data.flows : null;
+            const haveFlows = Boolean(this._data.flows) || Boolean(errorFlows);
+            if (error.httpStatus !== 401 || !error.data || !haveFlows) {
+                // doesn't look like an interactive-auth failure.
+                if (!background) {
+                    this._rejectFunc(error);
+                } else {
+                    // We ignore all failures here (even non-UI auth related ones)
+                    // since we don't want to suddenly fail if the internet connection
+                    // had a blip whilst we were polling
+                    _logger2.default.log("Background poll request failed doing UI auth: ignoring", error);
+                }
+            }
+            // if the error didn't come with flows, completed flows or session ID,
+            // copy over the ones we have. Synapse sometimes sends responses without
+            // any UI auth data (eg. when polling for email validation, if the email
+            // has not yet been validated). This appears to be a Synapse bug, which
+            // we workaround here.
+            if (!error.data.flows && !error.data.completed && !error.data.session) {
+                error.data.flows = this._data.flows;
+                error.data.completed = this._data.completed;
+                error.data.session = this._data.session;
+            }
+            this._data = error.data;
+            this._startNextAuthStage();
 
-        prom.then(
-            function(result) {
-                console.log("result from request: ", result);
-                self._completionDeferred.resolve(result);
-            }, function(error) {
-                if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
-                    // doesn't look like an interactive-auth failure. fail the whole lot.
-                    throw error;
+            if (!this._emailSid && !this._requestingEmailToken && this._chosenFlow.stages.includes('m.login.email.identity')) {
+                // If we've picked a flow with email auth, we send the email
+                // now because we want the request to fail as soon as possible
+                // if the email address is not valid (ie. already taken or not
+                // registered, depending on what the operation is).
+                this._requestingEmailToken = true;
+                try {
+                    const requestTokenResult = await this._requestEmailTokenCallback(this._inputs.emailAddress, this._clientSecret, 1, // TODO: Multiple send attempts?
+                    this._data.session);
+                    this._emailSid = requestTokenResult.sid;
+                    // NB. promise is not resolved here - at some point, doRequest
+                    // will be called again and if the user has jumped through all
+                    // the hoops correctly, auth will be complete and the request
+                    // will succeed.
+                    // Also, we should expose the fact that this request has compledted
+                    // so clients can know that the email has actually been sent.
+                } catch (e) {
+                    // we failed to request an email token, so fail the request.
+                    // This could be due to the email already beeing registered
+                    // (or not being registered, depending on what we're trying
+                    // to do) or it could be a network failure. Either way, pass
+                    // the failure up as the user can't complete auth if we can't
+                    // send the email, foe whatever reason.
+                    this._rejectFunc(e);
+                } finally {
+                    this._requestingEmailToken = false;
                 }
-                self._data = error.data;
-                self._startNextAuthStage();
             }
-        ).catch(this._completionDeferred.reject).done();
+        }
     },
 
     /**
      * Pick the next stage and call the callback
      *
      * @private
+     * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found
      */
-    _startNextAuthStage: function() {
-        var nextStage = this._chooseStage();
+    _startNextAuthStage: function () {
+        const nextStage = this._chooseStage();
         if (!nextStage) {
             throw new Error("No incomplete flows from the server");
         }
+        this._currentStage = nextStage;
 
-        var stageError = null;
+        if (nextStage === 'm.login.dummy') {
+            this.submitAuthDict({
+                type: 'm.login.dummy'
+            });
+            return;
+        }
+
         if (this._data.errcode || this._data.error) {
-            stageError = {
+            this._stateUpdatedCallback(nextStage, {
                 errcode: this._data.errcode || "",
-                error: this._data.error || "",
-            };
+                error: this._data.error || ""
+            });
+            return;
         }
-        this._startAuthStageCallback(nextStage, stageError);
+
+        const stageStatus = {};
+        if (nextStage == EMAIL_STAGE_TYPE) {
+            stageStatus.emailSid = this._emailSid;
+        }
+        this._stateUpdatedCallback(nextStage, stageStatus);
     },
 
     /**
      * Pick the next auth stage
      *
      * @private
      * @return {string?} login type
+     * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found
      */
-    _chooseStage: function() {
-        var flow = this._chooseFlow();
-        console.log("Active flow => %s", JSON.stringify(flow));
-        var nextStage = this._firstUncompletedStage(flow);
-        console.log("Next stage: %s", nextStage);
+    _chooseStage: function () {
+        if (this._chosenFlow === null) {
+            this._chosenFlow = this._chooseFlow();
+        }
+        _logger2.default.log("Active flow => %s", JSON.stringify(this._chosenFlow));
+        const nextStage = this._firstUncompletedStage(this._chosenFlow);
+        _logger2.default.log("Next stage: %s", nextStage);
         return nextStage;
     },
 
     /**
      * Pick one of the flows from the returned list
+     * If a flow using all of the inputs is found, it will
+     * be returned, otherwise, null will be returned.
+     *
+     * Only flows using all given inputs are chosen because it
+     * is likley to be surprising if the user provides a
+     * credential and it is not used. For example, for registration,
+     * this could result in the email not being used which would leave
+     * the account with no means to reset a password.
      *
      * @private
      * @return {object} flow
+     * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found
      */
-    _chooseFlow: function() {
-        var flows = this._data.flows || [];
-        // always use the first flow for now
-        return flows[0];
+    _chooseFlow: function () {
+        const flows = this._data.flows || [];
+
+        // we've been given an email or we've already done an email part
+        const haveEmail = Boolean(this._inputs.emailAddress) || Boolean(this._emailSid);
+        const haveMsisdn = Boolean(this._inputs.phoneCountry) && Boolean(this._inputs.phoneNumber);
+
+        for (const flow of flows) {
+            let flowHasEmail = false;
+            let flowHasMsisdn = false;
+            for (const stage of flow.stages) {
+                if (stage === EMAIL_STAGE_TYPE) {
+                    flowHasEmail = true;
+                } else if (stage == MSISDN_STAGE_TYPE) {
+                    flowHasMsisdn = true;
+                }
+            }
+
+            if (flowHasEmail == haveEmail && flowHasMsisdn == haveMsisdn) {
+                return flow;
+            }
+        }
+        // Throw an error with a fairly generic description, but with more
+        // information such that the app can give a better one if so desired.
+        const err = new Error("No appropriate authentication flow found");
+        err.name = 'NoAuthFlowFoundError';
+        err.required_stages = [];
+        if (haveEmail) err.required_stages.push(EMAIL_STAGE_TYPE);
+        if (haveMsisdn) err.required_stages.push(MSISDN_STAGE_TYPE);
+        err.available_flows = flows;
+        throw err;
     },
 
     /**
      * Get the first uncompleted stage in the given flow
      *
      * @private
      * @param {object} flow
      * @return {string} login type
      */
-    _firstUncompletedStage: function(flow) {
-        var completed = (this._data || {}).completed || [];
-        for (var i = 0; i < flow.stages.length; ++i) {
-            var stageType = flow.stages[i];
+    _firstUncompletedStage: function (flow) {
+        const completed = (this._data || {}).completed || [];
+        for (let i = 0; i < flow.stages.length; ++i) {
+            const stageType = flow.stages[i];
             if (completed.indexOf(stageType) === -1) {
                 return stageType;
             }
         }
-    },
+    }
 };
 
-
 /** */
-module.exports = InteractiveAuth;
+module.exports = InteractiveAuth;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/logger.js
@@ -0,0 +1,38 @@
+"use strict";
+
+/*
+Copyright 2018 André Jaenisch
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * @module logger
+ */
+const log = require("loglevel");
+
+// This is to demonstrate, that you can use any namespace you want.
+// Namespaces allow you to turn on/off the logging for specific parts of the
+// application.
+// An idea would be to control this via an environment variable (on Node.js).
+// See https://www.npmjs.com/package/debug to see how this could be implemented
+// Part of #332 is introducing a logging library in the first place.
+const DEFAULT_NAME_SPACE = "matrix";
+const logger = log.getLogger(DEFAULT_NAME_SPACE);
+logger.setLevel(log.levels.DEBUG);
+
+/**
+ * Drop-in replacement for <code>console</code> using {@link https://www.npmjs.com/package/loglevel|loglevel}.
+ * Can be tailored down to specific use cases if needed.
+*/
+module.exports = logger;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/matrix.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/matrix.js
@@ -1,42 +1,60 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 
+/** The {@link module:ContentHelpers} object */
+
+module.exports.ContentHelpers = require("./content-helpers");
 /** The {@link module:models/event.MatrixEvent|MatrixEvent} class. */
 module.exports.MatrixEvent = require("./models/event").MatrixEvent;
 /** The {@link module:models/event.EventStatus|EventStatus} enum. */
 module.exports.EventStatus = require("./models/event").EventStatus;
-/** The {@link module:store/memory.MatrixInMemoryStore|MatrixInMemoryStore} class. */
-module.exports.MatrixInMemoryStore = require("./store/memory").MatrixInMemoryStore;
-/** The {@link module:store/webstorage~WebStorageStore|WebStorageStore} class.
- * <strong>Work in progress; unstable.</strong> */
-module.exports.WebStorageStore = require("./store/webstorage");
+/** The {@link module:store/memory.MemoryStore|MemoryStore} class. */
+module.exports.MemoryStore = require("./store/memory").MemoryStore;
+/**
+ * The {@link module:store/memory.MemoryStore|MemoryStore} class was previously
+ * exported as `MatrixInMemoryStore`, so this is preserved for SDK consumers.
+ * @deprecated Prefer `MemoryStore` going forward.
+ */
+module.exports.MatrixInMemoryStore = module.exports.MemoryStore;
+/** The {@link module:store/indexeddb.IndexedDBStore|IndexedDBStore} class. */
+module.exports.IndexedDBStore = require("./store/indexeddb").IndexedDBStore;
+/** The {@link module:store/indexeddb.IndexedDBStoreBackend|IndexedDBStoreBackend} class. */
+module.exports.IndexedDBStoreBackend = require("./store/indexeddb").IndexedDBStoreBackend;
+/** The {@link module:sync-accumulator.SyncAccumulator|SyncAccumulator} class. */
+module.exports.SyncAccumulator = require("./sync-accumulator");
 /** The {@link module:http-api.MatrixHttpApi|MatrixHttpApi} class. */
 module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi;
 /** The {@link module:http-api.MatrixError|MatrixError} class. */
 module.exports.MatrixError = require("./http-api").MatrixError;
+/** The {@link module:errors.InvalidStoreError|InvalidStoreError} class. */
+module.exports.InvalidStoreError = require("./errors").InvalidStoreError;
 /** The {@link module:client.MatrixClient|MatrixClient} class. */
 module.exports.MatrixClient = require("./client").MatrixClient;
 /** The {@link module:models/room|Room} class. */
 module.exports.Room = require("./models/room");
+/** The {@link module:models/group|Group} class. */
+module.exports.Group = require("./models/group");
 /** The {@link module:models/event-timeline~EventTimeline} class. */
 module.exports.EventTimeline = require("./models/event-timeline");
 /** The {@link module:models/event-timeline-set~EventTimelineSet} class. */
 module.exports.EventTimelineSet = require("./models/event-timeline-set");
 /** The {@link module:models/room-member|RoomMember} class. */
 module.exports.RoomMember = require("./models/room-member");
 /** The {@link module:models/room-state~RoomState|RoomState} class. */
 module.exports.RoomState = require("./models/room-state");
@@ -52,90 +70,138 @@ module.exports.CRYPTO_ENABLED = require(
 /** {@link module:content-repo|ContentRepo} utility functions. */
 module.exports.ContentRepo = require("./content-repo");
 /** The {@link module:filter~Filter|Filter} class. */
 module.exports.Filter = require("./filter");
 /** The {@link module:timeline-window~TimelineWindow} class. */
 module.exports.TimelineWindow = require("./timeline-window").TimelineWindow;
 /** The {@link module:interactive-auth} class. */
 module.exports.InteractiveAuth = require("./interactive-auth");
+/** The {@link module:auto-discovery|AutoDiscovery} class. */
+module.exports.AutoDiscovery = require("./autodiscovery").AutoDiscovery;
 
+module.exports.SERVICE_TYPES = require('./service-types').SERVICE_TYPES;
+
+module.exports.MemoryCryptoStore = require("./crypto/store/memory-crypto-store").default;
+module.exports.IndexedDBCryptoStore = require("./crypto/store/indexeddb-crypto-store").default;
 
 /**
  * Create a new Matrix Call.
  * @function
  * @param {module:client.MatrixClient} client The MatrixClient instance to use.
  * @param {string} roomId The room the call is in.
  * @return {module:webrtc/call~MatrixCall} The Matrix call or null if the browser
  * does not support WebRTC.
  */
 module.exports.createNewMatrixCall = require("./webrtc/call").createNewMatrixCall;
 
+/**
+ * Set a preferred audio output device to use for MatrixCalls
+ * @function
+ * @param {string=} deviceId the identifier for the device
+ * undefined treated as unset
+ */
+module.exports.setMatrixCallAudioOutput = require('./webrtc/call').setAudioOutput;
+/**
+ * Set a preferred audio input device to use for MatrixCalls
+ * @function
+ * @param {string=} deviceId the identifier for the device
+ * undefined treated as unset
+ */
+module.exports.setMatrixCallAudioInput = require('./webrtc/call').setAudioInput;
+/**
+ * Set a preferred video input device to use for MatrixCalls
+ * @function
+ * @param {string=} deviceId the identifier for the device
+ * undefined treated as unset
+ */
+module.exports.setMatrixCallVideoInput = require('./webrtc/call').setVideoInput;
+
 // expose the underlying request object so different environments can use
 // different request libs (e.g. request or browser-request)
-var request;
+let request;
 /**
  * The function used to perform HTTP requests. Only use this if you want to
  * use a different HTTP library, e.g. Angular's <code>$http</code>. This should
  * be set prior to calling {@link createClient}.
  * @param {requestFunction} r The request function to use.
  */
-module.exports.request = function(r) {
-    request = r;
+module.exports.request = function (r) {
+  request = r;
 };
 
 /**
  * Return the currently-set request function.
  * @return {requestFunction} The current request function.
  */
-module.exports.getRequest = function() {
-    return request;
+module.exports.getRequest = function () {
+  return request;
 };
 
 /**
  * Apply wrapping code around the request function. The wrapper function is
  * installed as the new request handler, and when invoked it is passed the
  * previous value, along with the options and callback arguments.
  * @param {requestWrapperFunction} wrapper The wrapping function.
  */
-module.exports.wrapRequest = function(wrapper) {
-    var origRequest = request;
-    request = function(options, callback) {
-        return wrapper(origRequest, options, callback);
-    };
+module.exports.wrapRequest = function (wrapper) {
+  const origRequest = request;
+  request = function (options, callback) {
+    return wrapper(origRequest, options, callback);
+  };
+};
+
+let cryptoStoreFactory = () => new module.exports.MemoryCryptoStore();
+
+/**
+ * Configure a different factory to be used for creating crypto stores
+ *
+ * @param {Function} fac  a function which will return a new
+ *    {@link module:crypto.store.base~CryptoStore}.
+ */
+module.exports.setCryptoStoreFactory = function (fac) {
+  cryptoStoreFactory = fac;
 };
 
 /**
  * Construct a Matrix Client. Similar to {@link module:client~MatrixClient}
  * except that the 'request', 'store' and 'scheduler' dependencies are satisfied.
  * @param {(Object|string)} opts The configuration options for this client. If
  * this is a string, it is assumed to be the base URL. These configuration
  * options will be passed directly to {@link module:client~MatrixClient}.
  * @param {Object} opts.store If not set, defaults to
- * {@link module:store/memory.MatrixInMemoryStore}.
+ * {@link module:store/memory.MemoryStore}.
  * @param {Object} opts.scheduler If not set, defaults to
  * {@link module:scheduler~MatrixScheduler}.
  * @param {requestFunction} opts.request If not set, defaults to the function
  * supplied to {@link request} which defaults to the request module from NPM.
+ *
+ * @param {module:crypto.store.base~CryptoStore=} opts.cryptoStore
+ *    crypto store implementation. Calls the factory supplied to
+ *    {@link setCryptoStoreFactory} if unspecified; or if no factory has been
+ *    specified, uses a default implementation (indexeddb in the browser,
+ *    in-memory otherwise).
+ *
  * @return {MatrixClient} A new matrix client.
  * @see {@link module:client~MatrixClient} for the full list of options for
  * <code>opts</code>.
  */
-module.exports.createClient = function(opts) {
-    if (typeof opts === "string") {
-        opts = {
-            "baseUrl": opts
-        };
-    }
-    opts.request = opts.request || request;
-    opts.store = opts.store || new module.exports.MatrixInMemoryStore({
-      localStorage: global.localStorage
-    });
-    opts.scheduler = opts.scheduler || new module.exports.MatrixScheduler();
-    return new module.exports.MatrixClient(opts);
+module.exports.createClient = function (opts) {
+  if (typeof opts === "string") {
+    opts = {
+      "baseUrl": opts
+    };
+  }
+  opts.request = opts.request || request;
+  opts.store = opts.store || new module.exports.MemoryStore({
+    localStorage: global.localStorage
+  });
+  opts.scheduler = opts.scheduler || new module.exports.MatrixScheduler();
+  opts.cryptoStore = opts.cryptoStore || cryptoStoreFactory();
+  return new module.exports.MatrixClient(opts);
 };
 
 /**
  * The request function interface for performing HTTP requests. This matches the
  * API for the {@link https://github.com/request/request#requestoptions-callback|
  * request NPM module}. The SDK will attempt to call this function in order to
  * perform an HTTP request.
  * @callback requestFunction
@@ -155,19 +221,19 @@ module.exports.createClient = function(o
  * @callback requestWrapperFunction
  * @param {requestFunction} origRequest The underlying request function being
  * wrapped
  * @param {Object} opts The options for this HTTP request, given in the same
  * form as {@link requestFunction}.
  * @param {requestCallback} callback The request callback.
  */
 
- /**
-  * The request callback interface for performing HTTP requests. This matches the
-  * API for the {@link https://github.com/request/request#requestoptions-callback|
-  * request NPM module}. The SDK will implement a callback which meets this
-  * interface in order to handle the HTTP response.
-  * @callback requestCallback
-  * @param {Error} err The error if one occurred, else falsey.
-  * @param {Object} response The HTTP response which consists of
-  * <code>{statusCode: {Number}, headers: {Object}}</code>
-  * @param {Object} body The parsed HTTP response body.
-  */
+/**
+ * The request callback interface for performing HTTP requests. This matches the
+ * API for the {@link https://github.com/request/request#requestoptions-callback|
+ * request NPM module}. The SDK will implement a callback which meets this
+ * interface in order to handle the HTTP response.
+ * @callback requestCallback
+ * @param {Error} err The error if one occurred, else falsey.
+ * @param {Object} response The HTTP response which consists of
+ * <code>{statusCode: {Number}, headers: {Object}}</code>
+ * @param {Object} body The parsed HTTP response body.
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/event-context.js
@@ -28,92 +28,93 @@ limitations under the License.
  *
  * It also stores pagination tokens for going backwards and forwards in the
  * timeline.
  *
  * @param {MatrixEvent} ourEvent  the event at the centre of this context
  *
  * @constructor
  */
+
 function EventContext(ourEvent) {
     this._timeline = [ourEvent];
     this._ourEventIndex = 0;
-    this._paginateTokens = {b: null, f: null};
+    this._paginateTokens = { b: null, f: null };
 
     // this is used by MatrixClient to keep track of active requests
-    this._paginateRequests = {b: null, f: null};
+    this._paginateRequests = { b: null, f: null };
 }
 
 /**
  * Get the main event of interest
  *
  * This is a convenience function for getTimeline()[getOurEventIndex()].
  *
  * @return {MatrixEvent} The event at the centre of this context.
  */
-EventContext.prototype.getEvent = function() {
+EventContext.prototype.getEvent = function () {
     return this._timeline[this._ourEventIndex];
 };
 
 /**
  * Get the list of events in this context
  *
  * @return {Array} An array of MatrixEvents
  */
-EventContext.prototype.getTimeline = function() {
+EventContext.prototype.getTimeline = function () {
     return this._timeline;
 };
 
 /**
  * Get the index in the timeline of our event
  *
  * @return {Number}
  */
-EventContext.prototype.getOurEventIndex = function() {
+EventContext.prototype.getOurEventIndex = function () {
     return this._ourEventIndex;
 };
 
 /**
  * Get a pagination token.
  *
  * @param {boolean} backwards   true to get the pagination token for going
  *                                  backwards in time
  * @return {string}
  */
-EventContext.prototype.getPaginateToken = function(backwards) {
+EventContext.prototype.getPaginateToken = function (backwards) {
     return this._paginateTokens[backwards ? 'b' : 'f'];
 };
 
 /**
  * Set a pagination token.
  *
  * Generally this will be used only by the matrix js sdk.
  *
  * @param {string} token        pagination token
  * @param {boolean} backwards   true to set the pagination token for going
  *                                   backwards in time
  */
-EventContext.prototype.setPaginateToken = function(token, backwards) {
+EventContext.prototype.setPaginateToken = function (token, backwards) {
     this._paginateTokens[backwards ? 'b' : 'f'] = token;
 };
 
 /**
  * Add more events to the timeline
  *
  * @param {Array} events      new events, in timeline order
  * @param {boolean} atStart   true to insert new events at the start
  */
-EventContext.prototype.addEvents = function(events, atStart) {
+EventContext.prototype.addEvents = function (events, atStart) {
     // TODO: should we share logic with Room.addEventsToTimeline?
     // Should Room even use EventContext?
 
     if (atStart) {
         this._timeline = events.concat(this._timeline);
         this._ourEventIndex += events.length;
     } else {
         this._timeline = this._timeline.concat(events);
     }
 };
 
 /**
  * The EventContext class
  */
-module.exports = EventContext;
+module.exports = EventContext;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline-set.js
@@ -12,28 +12,43 @@ distributed under the License is distrib
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * @module models/event-timeline-set
  */
-var EventEmitter = require("events").EventEmitter;
-var utils = require("../utils");
-var EventTimeline = require("./event-timeline");
+
+var _event = require("./event");
+
+var _logger = require("../../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _relations = require("./relations");
+
+var _relations2 = _interopRequireDefault(_relations);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const EventEmitter = require("events").EventEmitter;
+const utils = require("../utils");
+const EventTimeline = require("./event-timeline");
+
 
 // var DEBUG = false;
-var DEBUG = true;
+const DEBUG = true;
 
+let debuglog;
 if (DEBUG) {
     // using bind means that we get to keep useful line numbers in the console
-    var debuglog = console.log.bind(console);
+    debuglog = _logger2.default.log.bind(_logger2.default);
 } else {
-    var debuglog = function() {};
+    debuglog = function () {};
 }
 
 /**
  * Construct a set of EventTimeline objects, typically on behalf of a given
  * room.  A room may have multiple EventTimelineSets for different levels
  * of filtering.  The global notification list is also an EventTimelineSet, but
  * lacks a room.
  *
@@ -48,194 +63,213 @@ if (DEBUG) {
  * from the /sync API. Note that you should not retain references to this
  * timeline - even if it is the current timeline right now, it may not remain
  * so if the server gives us a timeline gap in /sync.
  *
  * <p>In order that we can find events from their ids later, we also maintain a
  * map from event_id to timeline and index.
  *
  * @constructor
- * @param {?Room} room      the optional room for this timelineSet
- * @param {Object} opts     hash of options inherited from Room.
- *      opts.timelineSupport gives whether timeline support is enabled
- *      opts.filter is the filter object, if any, for this timelineSet.
+ * @param {?Room} room
+ * Room for this timelineSet. May be null for non-room cases, such as the
+ * notification timeline.
+ * @param {Object} opts Options inherited from Room.
+ *
+ * @param {boolean} [opts.timelineSupport = false]
+ * Set to true to enable improved timeline support.
+ * @param {Object} [opts.filter = null]
+ * The filter object, if any, for this timelineSet.
+ * @param {boolean} [opts.unstableClientRelationAggregation = false]
+ * Optional. Set to true to enable client-side aggregation of event relations
+ * via `getRelationsForEvent`.
+ * This feature is currently unstable and the API may change without notice.
  */
 function EventTimelineSet(room, opts) {
     this.room = room;
 
     this._timelineSupport = Boolean(opts.timelineSupport);
     this._liveTimeline = new EventTimeline(this);
+    this._unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
 
     // just a list - *not* ordered.
     this._timelines = [this._liveTimeline];
     this._eventIdToTimeline = {};
 
     this._filter = opts.filter || null;
+
+    if (this._unstableClientRelationAggregation) {
+        // A tree of objects to access a set of relations for an event, as in:
+        // this._relations[relatesToEventId][relationType][relationEventType]
+        this._relations = {};
+    }
 }
 utils.inherits(EventTimelineSet, EventEmitter);
 
 /**
  * Get the filter object this timeline set is filtered on, if any
  * @return {?Filter} the optional filter for this timelineSet
  */
-EventTimelineSet.prototype.getFilter = function() {
+EventTimelineSet.prototype.getFilter = function () {
     return this._filter;
 };
 
 /**
  * Set the filter object this timeline set is filtered on
  * (passed to the server when paginating via /messages).
  * @param {Filter} filter the filter for this timelineSet
  */
-EventTimelineSet.prototype.setFilter = function(filter) {
+EventTimelineSet.prototype.setFilter = function (filter) {
     this._filter = filter;
 };
 
 /**
  * Get the list of pending sent events for this timelineSet's room, filtered
  * by the timelineSet's filter if appropriate.
  *
  * @return {module:models/event.MatrixEvent[]} A list of the sent events
  * waiting for remote echo.
  *
  * @throws If <code>opts.pendingEventOrdering</code> was not 'detached'
  */
-EventTimelineSet.prototype.getPendingEvents = function() {
+EventTimelineSet.prototype.getPendingEvents = function () {
     if (!this.room) {
         return [];
     }
 
     if (this._filter) {
         return this._filter.filterRoomTimeline(this.room.getPendingEvents());
-    }
-    else {
+    } else {
         return this.room.getPendingEvents();
     }
 };
 
 /**
  * Get the live timeline for this room.
  *
  * @return {module:models/event-timeline~EventTimeline} live timeline
  */
-EventTimelineSet.prototype.getLiveTimeline = function() {
+EventTimelineSet.prototype.getLiveTimeline = function () {
     return this._liveTimeline;
 };
 
 /**
  * Return the timeline (if any) this event is in.
  * @param {String} eventId the eventId being sought
  * @return {module:models/event-timeline~EventTimeline} timeline
  */
-EventTimelineSet.prototype.eventIdToTimeline = function(eventId) {
+EventTimelineSet.prototype.eventIdToTimeline = function (eventId) {
     return this._eventIdToTimeline[eventId];
 };
 
 /**
  * Track a new event as if it were in the same timeline as an old event,
  * replacing it.
  * @param {String} oldEventId  event ID of the original event
  * @param {String} newEventId  event ID of the replacement event
  */
-EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) {
-    var existingTimeline = this._eventIdToTimeline[oldEventId];
+EventTimelineSet.prototype.replaceEventId = function (oldEventId, newEventId) {
+    const existingTimeline = this._eventIdToTimeline[oldEventId];
     if (existingTimeline) {
         delete this._eventIdToTimeline[oldEventId];
         this._eventIdToTimeline[newEventId] = existingTimeline;
     }
 };
 
 /**
  * Reset the live timeline, and start a new one.
  *
  * <p>This is used when /sync returns a 'limited' timeline.
  *
  * @param {string=} backPaginationToken   token for back-paginating the new timeline
- * @param {?bool} flush  Whether to flush the non-live timelines too.
+ * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline,
+ * if absent or null, all timelines are reset.
  *
  * @fires module:client~MatrixClient#event:"Room.timelineReset"
  */
-EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken, flush) {
-    var newTimeline;
+EventTimelineSet.prototype.resetLiveTimeline = function (backPaginationToken, forwardPaginationToken) {
+    // Each EventTimeline has RoomState objects tracking the state at the start
+    // and end of that timeline. The copies at the end of the live timeline are
+    // special because they will have listeners attached to monitor changes to
+    // the current room state, so we move this RoomState from the end of the
+    // current live timeline to the end of the new one and, if necessary,
+    // replace it with a newly created one. We also make a copy for the start
+    // of the new timeline.
 
-    if (!this._timelineSupport || flush) {
-        // if timeline support is disabled, forget about the old timelines
-        newTimeline = new EventTimeline(this);
+    // if timeline support is disabled, forget about the old timelines
+    const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken;
+
+    const oldTimeline = this._liveTimeline;
+    const newTimeline = resetAllTimelines ? oldTimeline.forkLive(EventTimeline.FORWARDS) : oldTimeline.fork(EventTimeline.FORWARDS);
+
+    if (resetAllTimelines) {
         this._timelines = [newTimeline];
         this._eventIdToTimeline = {};
     } else {
-        newTimeline = this.addTimeline();
+        this._timelines.push(newTimeline);
     }
 
-    // initialise the state in the new timeline from our last known state
-    var evMap = this._liveTimeline.getState(EventTimeline.FORWARDS).events;
-    var events = [];
-    for (var evtype in evMap) {
-        if (!evMap.hasOwnProperty(evtype)) { continue; }
-        for (var stateKey in evMap[evtype]) {
-            if (!evMap[evtype].hasOwnProperty(stateKey)) { continue; }
-            events.push(evMap[evtype][stateKey]);
-        }
+    if (forwardPaginationToken) {
+        // Now set the forward pagination token on the old live timeline
+        // so it can be forward-paginated.
+        oldTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS);
     }
-    newTimeline.initialiseState(events);
 
     // make sure we set the pagination token before firing timelineReset,
     // otherwise clients which start back-paginating will fail, and then get
     // stuck without realising that they *can* back-paginate.
     newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS);
 
+    // Now we can swap the live timeline to the new one.
     this._liveTimeline = newTimeline;
-    this.emit("Room.timelineReset", this.room, this);
+    this.emit("Room.timelineReset", this.room, this, resetAllTimelines);
 };
 
 /**
  * Get the timeline which contains the given event, if any
  *
  * @param {string} eventId  event ID to look for
  * @return {?module:models/event-timeline~EventTimeline} timeline containing
  * the given event, or null if unknown
  */
-EventTimelineSet.prototype.getTimelineForEvent = function(eventId) {
-    var res = this._eventIdToTimeline[eventId];
-    return (res === undefined) ? null : res;
+EventTimelineSet.prototype.getTimelineForEvent = function (eventId) {
+    const res = this._eventIdToTimeline[eventId];
+    return res === undefined ? null : res;
 };
 
 /**
  * Get an event which is stored in our timelines
  *
  * @param {string} eventId  event ID to look for
  * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown
  */
-EventTimelineSet.prototype.findEventById = function(eventId) {
-    var tl = this.getTimelineForEvent(eventId);
+EventTimelineSet.prototype.findEventById = function (eventId) {
+    const tl = this.getTimelineForEvent(eventId);
     if (!tl) {
         return undefined;
     }
-    return utils.findElement(tl.getEvents(),
-                             function(ev) { return ev.getId() == eventId; });
+    return utils.findElement(tl.getEvents(), function (ev) {
+        return ev.getId() == eventId;
+    });
 };
 
 /**
  * Add a new timeline to this timeline list
  *
  * @return {module:models/event-timeline~EventTimeline} newly-created timeline
  */
-EventTimelineSet.prototype.addTimeline = function() {
+EventTimelineSet.prototype.addTimeline = function () {
     if (!this._timelineSupport) {
-        throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
-                        " parameter to true when creating MatrixClient to enable" +
-                        " it.");
+        throw new Error("timeline support is disabled. Set the 'timelineSupport'" + " parameter to true when creating MatrixClient to enable" + " it.");
     }
 
-    var timeline = new EventTimeline(this);
+    const timeline = new EventTimeline(this);
     this._timelines.push(timeline);
     return timeline;
 };
 
-
 /**
  * Add events to a timeline
  *
  * <p>Will fire "Room.timeline" for each event added.
  *
  * @param {MatrixEvent[]} events A list of events to add.
  *
  * @param {boolean} toStartOfTimeline   True to add these events to the start
@@ -245,42 +279,34 @@ EventTimelineSet.prototype.addTimeline =
  * @param {module:models/event-timeline~EventTimeline} timeline   timeline to
  *    add events to.
  *
  * @param {string=} paginationToken   token for the next batch of events
  *
  * @fires module:client~MatrixClient#event:"Room.timeline"
  *
  */
-EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
-                                              timeline, paginationToken) {
+EventTimelineSet.prototype.addEventsToTimeline = function (events, toStartOfTimeline, timeline, paginationToken) {
     if (!timeline) {
-        throw new Error(
-            "'timeline' not specified for EventTimelineSet.addEventsToTimeline"
-        );
+        throw new Error("'timeline' not specified for EventTimelineSet.addEventsToTimeline");
     }
 
     if (!toStartOfTimeline && timeline == this._liveTimeline) {
-        throw new Error(
-            "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
-            "the live timeline - use Room.addLiveEvents instead"
-        );
+        throw new Error("EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + "the live timeline - use Room.addLiveEvents instead");
     }
 
     if (this._filter) {
         events = this._filter.filterRoomTimeline(events);
         if (!events.length) {
             return;
         }
     }
 
-    var direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
-        EventTimeline.FORWARDS;
-    var inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
-        EventTimeline.BACKWARDS;
+    const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
+    const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
 
     // Adding events to timelines can be quite complicated. The following
     // illustrates some of the corner-cases.
     //
     // Let's say we start by knowing about four timelines. timeline3 and
     // timeline4 are neighbours:
     //
     //    timeline1    timeline2    timeline3    timeline4
@@ -341,119 +367,135 @@ EventTimelineSet.prototype.addEventsToTi
     //
     // We make an exception for this if it turns out that we already knew about
     // *all* of the events, and we weren't able to join up any timelines. When
     // that happens, it means our existing pagination token is faulty, since it
     // is only telling us what we already know. Rather than repeatedly
     // paginating with the same token, we might as well use the new pagination
     // token in the hope that we eventually work our way out of the mess.
 
-    var didUpdate = false;
-    var lastEventWasNew = false;
-    for (var i = 0; i < events.length; i++) {
-        var event = events[i];
-        var eventId = event.getId();
+    let didUpdate = false;
+    let lastEventWasNew = false;
+    for (let i = 0; i < events.length; i++) {
+        const event = events[i];
+        const eventId = event.getId();
 
-        var existingTimeline = this._eventIdToTimeline[eventId];
+        const existingTimeline = this._eventIdToTimeline[eventId];
 
         if (!existingTimeline) {
             // we don't know about this event yet. Just add it to the timeline.
             this.addEventToTimeline(event, timeline, toStartOfTimeline);
             lastEventWasNew = true;
             didUpdate = true;
             continue;
         }
 
         lastEventWasNew = false;
 
         if (existingTimeline == timeline) {
             debuglog("Event " + eventId + " already in timeline " + timeline);
             continue;
         }
 
-        var neighbour = timeline.getNeighbouringTimeline(direction);
+        const neighbour = timeline.getNeighbouringTimeline(direction);
         if (neighbour) {
             // this timeline already has a neighbour in the relevant direction;
             // let's assume the timelines are already correctly linked up, and
             // skip over to it.
             //
             // there's probably some edge-case here where we end up with an
             // event which is in a timeline a way down the chain, and there is
             // a break in the chain somewhere. But I can't really imagine how
             // that would happen, so I'm going to ignore it for now.
             //
             if (existingTimeline == neighbour) {
-                debuglog("Event " + eventId + " in neighbouring timeline - " +
-                            "switching to " + existingTimeline);
+                debuglog("Event " + eventId + " in neighbouring timeline - " + "switching to " + existingTimeline);
             } else {
-                debuglog("Event " + eventId + " already in a different " +
-                            "timeline " + existingTimeline);
+                debuglog("Event " + eventId + " already in a different " + "timeline " + existingTimeline);
             }
             timeline = existingTimeline;
             continue;
         }
 
         // time to join the timelines.
-        console.info("Already have timeline for " + eventId +
-                     " - joining timeline " + timeline + " to " +
-                     existingTimeline);
+        _logger2.default.info("Already have timeline for " + eventId + " - joining timeline " + timeline + " to " + existingTimeline);
+
+        // Variables to keep the line length limited below.
+        const existingIsLive = existingTimeline === this._liveTimeline;
+        const timelineIsLive = timeline === this._liveTimeline;
+
+        const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive;
+        const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive;
+
+        if (backwardsIsLive || forwardsIsLive) {
+            // The live timeline should never be spliced into a non-live position.
+            // We use independent logging to better discover the problem at a glance.
+            if (backwardsIsLive) {
+                _logger2.default.warn("Refusing to set a preceding existingTimeLine on our " + "timeline as the existingTimeLine is live (" + existingTimeline + ")");
+            }
+            if (forwardsIsLive) {
+                _logger2.default.warn("Refusing to set our preceding timeline on a existingTimeLine " + "as our timeline is live (" + timeline + ")");
+            }
+            continue; // abort splicing - try next event
+        }
+
         timeline.setNeighbouringTimeline(existingTimeline, direction);
         existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
+
         timeline = existingTimeline;
         didUpdate = true;
     }
 
     // see above - if the last event was new to us, or if we didn't find any
     // new information, we update the pagination token for whatever
     // timeline we ended up on.
     if (lastEventWasNew || !didUpdate) {
+        if (direction === EventTimeline.FORWARDS && timeline === this._liveTimeline) {
+            _logger2.default.warn({ lastEventWasNew, didUpdate }); // for debugging
+            _logger2.default.warn(`Refusing to set forwards pagination token of live timeline ` + `${timeline} to ${paginationToken}`);
+            return;
+        }
         timeline.setPaginationToken(paginationToken, direction);
     }
 };
 
 /**
  * Add an event to the end of this live timeline.
  *
  * @param {MatrixEvent} event Event to be added
  * @param {string?} duplicateStrategy 'ignore' or 'replace'
  */
-EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) {
+EventTimelineSet.prototype.addLiveEvent = function (event, duplicateStrategy) {
     if (this._filter) {
-        var events = this._filter.filterRoomTimeline([event]);
+        const events = this._filter.filterRoomTimeline([event]);
         if (!events.length) {
             return;
         }
     }
 
-    var timeline = this._eventIdToTimeline[event.getId()];
+    const timeline = this._eventIdToTimeline[event.getId()];
     if (timeline) {
         if (duplicateStrategy === "replace") {
-            debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
-                     event.getId());
-            var tlEvents = timeline.getEvents();
-            for (var j = 0; j < tlEvents.length; j++) {
+            debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId());
+            const tlEvents = timeline.getEvents();
+            for (let j = 0; j < tlEvents.length; j++) {
                 if (tlEvents[j].getId() === event.getId()) {
                     // still need to set the right metadata on this event
-                    EventTimeline.setEventMetadata(
-                        event,
-                        timeline.getState(EventTimeline.FORWARDS),
-                        false
-                    );
+                    EventTimeline.setEventMetadata(event, timeline.getState(EventTimeline.FORWARDS), false);
 
                     if (!tlEvents[j].encryptedType) {
                         tlEvents[j] = event;
                     }
 
                     // XXX: we need to fire an event when this happens.
                     break;
                 }
             }
         } else {
-            debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
-                     event.getId());
+            debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId());
         }
         return;
     }
 
     this.addEventToTimeline(event, this._liveTimeline, false);
 };
 
 /**
@@ -463,78 +505,77 @@ EventTimelineSet.prototype.addLiveEvent 
  * Will fire "Room.timeline" for each event added.
  *
  * @param {MatrixEvent} event
  * @param {EventTimeline} timeline
  * @param {boolean} toStartOfTimeline
  *
  * @fires module:client~MatrixClient#event:"Room.timeline"
  */
-EventTimelineSet.prototype.addEventToTimeline = function(event, timeline,
-                                                         toStartOfTimeline) {
-    var eventId = event.getId();
+EventTimelineSet.prototype.addEventToTimeline = function (event, timeline, toStartOfTimeline) {
+    const eventId = event.getId();
     timeline.addEvent(event, toStartOfTimeline);
     this._eventIdToTimeline[eventId] = timeline;
 
-    var data = {
+    this.setRelationsTarget(event);
+    this.aggregateRelations(event);
+
+    const data = {
         timeline: timeline,
-        liveEvent: !toStartOfTimeline && timeline == this._liveTimeline,
+        liveEvent: !toStartOfTimeline && timeline == this._liveTimeline
     };
-    this.emit("Room.timeline", event, this.room,
-              Boolean(toStartOfTimeline), false, data);
+    this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data);
 };
 
 /**
  * Replaces event with ID oldEventId with one with newEventId, if oldEventId is
  * recognised.  Otherwise, add to the live timeline.  Used to handle remote echos.
  *
  * @param {MatrixEvent} localEvent     the new event to be added to the timeline
  * @param {String} oldEventId          the ID of the original event
  * @param {boolean} newEventId         the ID of the replacement event
  *
  * @fires module:client~MatrixClient#event:"Room.timeline"
  */
-EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId,
-                                                        newEventId) {
+EventTimelineSet.prototype.handleRemoteEcho = function (localEvent, oldEventId, newEventId) {
     // XXX: why don't we infer newEventId from localEvent?
-    var existingTimeline = this._eventIdToTimeline[oldEventId];
+    const existingTimeline = this._eventIdToTimeline[oldEventId];
     if (existingTimeline) {
         delete this._eventIdToTimeline[oldEventId];
         this._eventIdToTimeline[newEventId] = existingTimeline;
     } else {
         if (this._filter) {
             if (this._filter.filterRoomTimeline([localEvent]).length) {
                 this.addEventToTimeline(localEvent, this._liveTimeline, false);
             }
-        }
-        else {
+        } else {
             this.addEventToTimeline(localEvent, this._liveTimeline, false);
         }
     }
 };
 
 /**
  * Removes a single event from this room.
  *
  * @param {String} eventId  The id of the event to remove
  *
  * @return {?MatrixEvent} the removed event, or null if the event was not found
  * in this room.
  */
-EventTimelineSet.prototype.removeEvent = function(eventId) {
-    var timeline = this._eventIdToTimeline[eventId];
+EventTimelineSet.prototype.removeEvent = function (eventId) {
+    const timeline = this._eventIdToTimeline[eventId];
     if (!timeline) {
         return null;
     }
 
-    var removed = timeline.removeEvent(eventId);
+    const removed = timeline.removeEvent(eventId);
     if (removed) {
         delete this._eventIdToTimeline[eventId];
-        var data = {
-            timeline: timeline,
+        const data = {
+            timeline: timeline
         };
         this.emit("Room.timeline", removed, this.room, undefined, true, data);
     }
     return removed;
 };
 
 /**
  * Determine where two events appear in the timeline relative to one another
@@ -543,55 +584,54 @@ EventTimelineSet.prototype.removeEvent =
  * @param {string} eventId2   The id of the second event
 
  * @return {?number} a number less than zero if eventId1 precedes eventId2, and
  *    greater than zero if eventId1 succeeds eventId2. zero if they are the
  *    same event; null if we can't tell (either because we don't know about one
  *    of the events, or because they are in separate timelines which don't join
  *    up).
  */
-EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) {
+EventTimelineSet.prototype.compareEventOrdering = function (eventId1, eventId2) {
     if (eventId1 == eventId2) {
         // optimise this case
         return 0;
     }
 
-    var timeline1 = this._eventIdToTimeline[eventId1];
-    var timeline2 = this._eventIdToTimeline[eventId2];
+    const timeline1 = this._eventIdToTimeline[eventId1];
+    const timeline2 = this._eventIdToTimeline[eventId2];
 
     if (timeline1 === undefined) {
         return null;
     }
     if (timeline2 === undefined) {
         return null;
     }
 
     if (timeline1 === timeline2) {
         // both events are in the same timeline - figure out their
         // relative indices
-        var idx1, idx2;
-        var events = timeline1.getEvents();
-        for (var idx = 0; idx < events.length &&
-             (idx1 === undefined || idx2 === undefined); idx++) {
-            var evId = events[idx].getId();
+        let idx1, idx2;
+        const events = timeline1.getEvents();
+        for (let idx = 0; idx < events.length && (idx1 === undefined || idx2 === undefined); idx++) {
+            const evId = events[idx].getId();
             if (evId == eventId1) {
                 idx1 = idx;
             }
             if (evId == eventId2) {
                 idx2 = idx;
             }
         }
         return idx1 - idx2;
     }
 
     // the events are in different timelines. Iterate through the
     // linkedlist to see which comes first.
 
     // first work forwards from timeline1
-    var tl = timeline1;
+    let tl = timeline1;
     while (tl) {
         if (tl === timeline2) {
             // timeline1 is before timeline2
             return -1;
         }
         tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
     }
 
@@ -605,16 +645,130 @@ EventTimelineSet.prototype.compareEventO
         tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
     }
 
     // the timelines are not contiguous.
     return null;
 };
 
 /**
+ * Get a collection of relations to a given event in this timeline set.
+ *
+ * @param {String} eventId
+ * The ID of the event that you'd like to access relation events for.
+ * For example, with annotations, this would be the ID of the event being annotated.
+ * @param {String} relationType
+ * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
+ * @param {String} eventType
+ * The relation event's type, such as "m.reaction", etc.
+ *
+ * @returns {Relations}
+ * A container for relation events.
+ */
+EventTimelineSet.prototype.getRelationsForEvent = function (eventId, relationType, eventType) {
+    if (!this._unstableClientRelationAggregation) {
+        throw new Error("Client-side relation aggregation is disabled");
+    }
+
+    if (!eventId || !relationType || !eventType) {
+        throw new Error("Invalid arguments for `getRelationsForEvent`");
+    }
+
+    // debuglog("Getting relations for: ", eventId, relationType, eventType);
+
+    const relationsForEvent = this._relations[eventId] || {};
+    const relationsWithRelType = relationsForEvent[relationType] || {};
+    return relationsWithRelType[eventType];
+};
+
+/**
+ * Set an event as the target event if any Relations exist for it already
+ *
+ * @param {MatrixEvent} event
+ * The event to check as relation target.
+ */
+EventTimelineSet.prototype.setRelationsTarget = function (event) {
+    if (!this._unstableClientRelationAggregation) {
+        return;
+    }
+
+    const relationsForEvent = this._relations[event.getId()];
+    if (!relationsForEvent) {
+        return;
+    }
+    // don't need it for non m.replace relations for now
+    const relationsWithRelType = relationsForEvent["m.replace"];
+    if (!relationsWithRelType) {
+        return;
+    }
+    // only doing replacements for messages for now (e.g. edits)
+    const relationsWithEventType = relationsWithRelType["m.room.message"];
+
+    if (relationsWithEventType) {
+        relationsWithEventType.setTargetEvent(event);
+    }
+};
+
+/**
+ * Add relation events to the relevant relation collection.
+ *
+ * @param {MatrixEvent} event
+ * The new relation event to be aggregated.
+ */
+EventTimelineSet.prototype.aggregateRelations = function (event) {
+    if (!this._unstableClientRelationAggregation) {
+        return;
+    }
+
+    if (event.isRedacted() || event.status === _event.EventStatus.CANCELLED) {
+        return;
+    }
+
+    // If the event is currently encrypted, wait until it has been decrypted.
+    if (event.isBeingDecrypted()) {
+        event.once("Event.decrypted", () => {
+            this.aggregateRelations(event);
+        });
+        return;
+    }
+
+    const relation = event.getRelation();
+    if (!relation) {
+        return;
+    }
+
+    const relatesToEventId = relation.event_id;
+    const relationType = relation.rel_type;
+    const eventType = event.getType();
+
+    // debuglog("Aggregating relation: ", event.getId(), eventType, relation);
+
+    let relationsForEvent = this._relations[relatesToEventId];
+    if (!relationsForEvent) {
+        relationsForEvent = this._relations[relatesToEventId] = {};
+    }
+    let relationsWithRelType = relationsForEvent[relationType];
+    if (!relationsWithRelType) {
+        relationsWithRelType = relationsForEvent[relationType] = {};
+    }
+    let relationsWithEventType = relationsWithRelType[eventType];
+
+    if (!relationsWithEventType) {
+        relationsWithEventType = relationsWithRelType[eventType] = new _relations2.default(relationType, eventType, this.room);
+        const relatesToEvent = this.findEventById(relatesToEventId);
+        if (relatesToEvent) {
+            relationsWithEventType.setTargetEvent(relatesToEvent);
+            relatesToEvent.emit("Event.relationsCreated", relationType, eventType);
+        }
+    }
+
+    relationsWithEventType.addEvent(event);
+};
+
+/**
  * The EventTimelineSet class.
  */
 module.exports = EventTimelineSet;
 
 /**
  * Fires whenever the timeline in a room is updated.
  * @event module:client~MatrixClient#"Room.timeline"
  * @param {MatrixEvent} event The matrix event which caused this event to fire.
@@ -646,9 +800,10 @@ module.exports = EventTimelineSet;
  * When we get a 'limited' sync (for example, after a network outage), we reset
  * the live timeline to be empty before adding the recent events to the new
  * timeline. This event is fired after the timeline is reset, and before the
  * new events are added.
  *
  * @event module:client~MatrixClient#"Room.timelineReset"
  * @param {Room} room The room whose live timeline was reset, if any
  * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset
- */
+ * @param {boolean} resetAllTimelines True if all timelines were reset.
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/event-timeline.js
@@ -14,19 +14,17 @@ See the License for the specific languag
 limitations under the License.
 */
 "use strict";
 
 /**
  * @module models/event-timeline
  */
 
-var RoomState = require("./room-state");
-var utils = require("../utils");
-var MatrixEvent = require("./event").MatrixEvent;
+const RoomState = require("./room-state");
 
 /**
  * Construct a new EventTimeline
  *
  * <p>An EventTimeline represents a contiguous sequence of events in a room.
  *
  * <p>As well as keeping track of the events themselves, it stores the state of
  * the room at the beginning and end of the timeline, and pagination tokens for
@@ -52,17 +50,17 @@ function EventTimeline(eventTimelineSet)
     this._startState.paginationToken = null;
     this._endState = new RoomState(this._roomId);
     this._endState.paginationToken = null;
 
     this._prevTimeline = null;
     this._nextTimeline = null;
 
     // this is used by client.js
-    this._paginationRequests = {'b': null, 'f': null};
+    this._paginationRequests = { 'b': null, 'f': null };
 
     this._name = this._roomId + ":" + new Date().toISOString();
 }
 
 /**
  * Symbolic constant for methods which take a 'direction' argument:
  * refers to the start of the timeline, or backwards in time.
  */
@@ -78,90 +76,143 @@ EventTimeline.FORWARDS = "f";
  * Initialise the start and end state with the given events
  *
  * <p>This can only be called before any events are added.
  *
  * @param {MatrixEvent[]} stateEvents list of state events to initialise the
  * state with.
  * @throws {Error} if an attempt is made to call this after addEvent is called.
  */
-EventTimeline.prototype.initialiseState = function(stateEvents) {
+EventTimeline.prototype.initialiseState = function (stateEvents) {
     if (this._events.length > 0) {
         throw new Error("Cannot initialise state after events are added");
     }
 
-    // we deep-copy the events here, in case they get changed later - we don't
-    // want changes to the start state leaking through to the end state.
-    var oldStateEvents = utils.map(
-        utils.deepCopy(
-            stateEvents.map(function(mxEvent) { return mxEvent.event; })
-        ), function(ev) { return new MatrixEvent(ev); });
+    // We previously deep copied events here and used different copies in
+    // the oldState and state events: this decision seems to date back
+    // quite a way and was apparently made to fix a bug where modifications
+    // made to the start state leaked through to the end state.
+    // This really shouldn't be possible though: the events themselves should
+    // not change. Duplicating the events uses a lot of extra memory,
+    // so we now no longer do it. To assert that they really do never change,
+    // freeze them! Note that we can't do this for events in general:
+    // although it looks like the only things preventing us are the
+    // 'status' flag, forwardLooking (which is only set once when adding to the
+    // timeline) and possibly the sender (which seems like it should never be
+    // reset but in practice causes a lot of the tests to break).
+    for (const e of stateEvents) {
+        Object.freeze(e);
+    }
+
+    this._startState.setStateEvents(stateEvents);
+    this._endState.setStateEvents(stateEvents);
+};
+
+/**
+ * Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
+ * All attached listeners will keep receiving state updates from the new live timeline state.
+ * The end state of this timeline gets replaced with an independent copy of the current RoomState,
+ * and will need a new pagination token if it ever needs to paginate forwards.
 
-    this._startState.setStateEvents(oldStateEvents);
-    this._endState.setStateEvents(stateEvents);
+ * @param {string} direction   EventTimeline.BACKWARDS to get the state at the
+ *   start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ *   of the timeline.
+ *
+ * @return {EventTimeline} the new timeline
+ */
+EventTimeline.prototype.forkLive = function (direction) {
+    const forkState = this.getState(direction);
+    const timeline = new EventTimeline(this._eventTimelineSet);
+    timeline._startState = forkState.clone();
+    // Now clobber the end state of the new live timeline with that from the
+    // previous live timeline. It will be identical except that we'll keep
+    // using the same RoomMember objects for the 'live' set of members with any
+    // listeners still attached
+    timeline._endState = forkState;
+    // Firstly, we just stole the current timeline's end state, so it needs a new one.
+    // Make an immutable copy of the state so back pagination will get the correct sentinels.
+    this._endState = forkState.clone();
+    return timeline;
+};
+
+/**
+ * Creates an independent timeline, inheriting the directional state from this timeline.
+ *
+ * @param {string} direction   EventTimeline.BACKWARDS to get the state at the
+ *   start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ *   of the timeline.
+ *
+ * @return {EventTimeline} the new timeline
+ */
+EventTimeline.prototype.fork = function (direction) {
+    const forkState = this.getState(direction);
+    const timeline = new EventTimeline(this._eventTimelineSet);
+    timeline._startState = forkState.clone();
+    timeline._endState = forkState.clone();
+    return timeline;
 };
 
 /**
  * Get the ID of the room for this timeline
  * @return {string} room ID
  */
-EventTimeline.prototype.getRoomId = function() {
+EventTimeline.prototype.getRoomId = function () {
     return this._roomId;
 };
 
 /**
  * Get the filter for this timeline's timelineSet (if any)
  * @return {Filter} filter
  */
-EventTimeline.prototype.getFilter = function() {
+EventTimeline.prototype.getFilter = function () {
     return this._eventTimelineSet.getFilter();
 };
 
 /**
  * Get the timelineSet for this timeline
  * @return {EventTimelineSet} timelineSet
  */
-EventTimeline.prototype.getTimelineSet = function() {
+EventTimeline.prototype.getTimelineSet = function () {
     return this._eventTimelineSet;
 };
 
 /**
  * Get the base index.
  *
  * <p>This is an index which is incremented when events are prepended to the
  * timeline. An individual event therefore stays at the same index in the array
  * relative to the base index (although note that a given event's index may
  * well be less than the base index, thus giving that event a negative relative
  * index).
  *
  * @return {number}
  */
-EventTimeline.prototype.getBaseIndex = function() {
+EventTimeline.prototype.getBaseIndex = function () {
     return this._baseIndex;
 };
 
 /**
  * Get the list of events in this context
  *
  * @return {MatrixEvent[]} An array of MatrixEvents
  */
-EventTimeline.prototype.getEvents = function() {
+EventTimeline.prototype.getEvents = function () {
     return this._events;
 };
 
 /**
  * Get the room state at the start/end of the timeline
  *
  * @param {string} direction   EventTimeline.BACKWARDS to get the state at the
  *   start of the timeline; EventTimeline.FORWARDS to get the state at the end
  *   of the timeline.
  *
  * @return {RoomState} state at the start/end of the timeline
  */
-EventTimeline.prototype.getState = function(direction) {
+EventTimeline.prototype.getState = function (direction) {
     if (direction == EventTimeline.BACKWARDS) {
         return this._startState;
     } else if (direction == EventTimeline.FORWARDS) {
         return this._endState;
     } else {
         throw new Error("Invalid direction '" + direction + "'");
     }
 };
@@ -170,43 +221,43 @@ EventTimeline.prototype.getState = funct
  * Get a pagination token
  *
  * @param {string} direction   EventTimeline.BACKWARDS to get the pagination
  *   token for going backwards in time; EventTimeline.FORWARDS to get the
  *   pagination token for going forwards in time.
  *
  * @return {?string} pagination token
  */
-EventTimeline.prototype.getPaginationToken = function(direction) {
+EventTimeline.prototype.getPaginationToken = function (direction) {
     return this.getState(direction).paginationToken;
 };
 
 /**
  * Set a pagination token
  *
  * @param {?string} token       pagination token
  *
  * @param {string} direction    EventTimeline.BACKWARDS to set the pagination
  *   token for going backwards in time; EventTimeline.FORWARDS to set the
  *   pagination token for going forwards in time.
  */
-EventTimeline.prototype.setPaginationToken = function(token, direction) {
+EventTimeline.prototype.setPaginationToken = function (token, direction) {
     this.getState(direction).paginationToken = token;
 };
 
 /**
  * Get the next timeline in the series
  *
  * @param {string} direction EventTimeline.BACKWARDS to get the previous
  *   timeline; EventTimeline.FORWARDS to get the next timeline.
  *
  * @return {?EventTimeline} previous or following timeline, if they have been
  * joined up.
  */
-EventTimeline.prototype.getNeighbouringTimeline = function(direction) {
+EventTimeline.prototype.getNeighbouringTimeline = function (direction) {
     if (direction == EventTimeline.BACKWARDS) {
         return this._prevTimeline;
     } else if (direction == EventTimeline.FORWARDS) {
         return this._nextTimeline;
     } else {
         throw new Error("Invalid direction '" + direction + "'");
     }
 };
@@ -217,20 +268,19 @@ EventTimeline.prototype.getNeighbouringT
  * @param {EventTimeline} neighbour previous/following timeline
  *
  * @param {string} direction EventTimeline.BACKWARDS to set the previous
  *   timeline; EventTimeline.FORWARDS to set the next timeline.
  *
  * @throws {Error} if an attempt is made to set the neighbouring timeline when
  * it is already set.
  */
-EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) {
+EventTimeline.prototype.setNeighbouringTimeline = function (neighbour, direction) {
     if (this.getNeighbouringTimeline(direction)) {
-        throw new Error("timeline already has a neighbouring timeline - " +
-                        "cannot reset neighbour");
+        throw new Error("timeline already has a neighbouring timeline - " + "cannot reset neighbour (direction: " + direction + ")");
     }
 
     if (direction == EventTimeline.BACKWARDS) {
         this._prevTimeline = neighbour;
     } else if (direction == EventTimeline.FORWARDS) {
         this._nextTimeline = neighbour;
     } else {
         throw new Error("Invalid direction '" + direction + "'");
@@ -241,46 +291,44 @@ EventTimeline.prototype.setNeighbouringT
 };
 
 /**
  * Add a new event to the timeline, and update the state
  *
  * @param {MatrixEvent} event   new event
  * @param {boolean}  atStart     true to insert new event at the start
  */
-EventTimeline.prototype.addEvent = function(event, atStart) {
-    var stateContext = atStart ? this._startState : this._endState;
+EventTimeline.prototype.addEvent = function (event, atStart) {
+    const stateContext = atStart ? this._startState : this._endState;
 
     // only call setEventMetadata on the unfiltered timelineSets
-    var timelineSet = this.getTimelineSet();
-    if (timelineSet.room &&
-        timelineSet.room.getUnfilteredTimelineSet() === timelineSet)
-    {
+    const timelineSet = this.getTimelineSet();
+    if (timelineSet.room && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
         EventTimeline.setEventMetadata(event, stateContext, atStart);
 
         // modify state
         if (event.isState()) {
             stateContext.setStateEvents([event]);
             // it is possible that the act of setting the state event means we
             // can set more metadata (specifically sender/target props), so try
             // it again if the prop wasn't previously set. It may also mean that
             // the sender/target is updated (if the event set was a room member event)
             // so we want to use the *updated* member (new avatar/name) instead.
             //
             // However, we do NOT want to do this on member events if we're going
             // back in time, else we'll set the .sender value for BEFORE the given
             // member event, whereas we want to set the .sender value for the ACTUAL
             // member event itself.
-            if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
+            if (!event.sender || event.getType() === "m.room.member" && !atStart) {
                 EventTimeline.setEventMetadata(event, stateContext, atStart);
             }
         }
     }
 
-    var insertIndex;
+    let insertIndex;
 
     if (atStart) {
         insertIndex = 0;
     } else {
         insertIndex = this._events.length;
     }
 
     this._events.splice(insertIndex, 0, event); // insert element
@@ -291,25 +339,21 @@ EventTimeline.prototype.addEvent = funct
 
 /**
  * Static helper method to set sender and target properties
  *
  * @param {MatrixEvent} event   the event whose metadata is to be set
  * @param {RoomState} stateContext  the room state to be queried
  * @param {bool} toStartOfTimeline  if true the event's forwardLooking flag is set false
  */
-EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) {
+EventTimeline.setEventMetadata = function (event, stateContext, toStartOfTimeline) {
     // set sender and target properties
-    event.sender = stateContext.getSentinelMember(
-        event.getSender()
-    );
+    event.sender = stateContext.getSentinelMember(event.getSender());
     if (event.getType() === "m.room.member") {
-        event.target = stateContext.getSentinelMember(
-            event.getStateKey()
-        );
+        event.target = stateContext.getSentinelMember(event.getStateKey());
     }
     if (event.isState()) {
         // room state has no concept of 'old' or 'current', but we want the
         // room state to regress back to previous values if toStartOfTimeline
         // is set, which means inspecting prev_content if it exists. This
         // is done by toggling the forwardLooking flag.
         if (toStartOfTimeline) {
             event.forwardLooking = false;
@@ -318,36 +362,35 @@ EventTimeline.setEventMetadata = functio
 };
 
 /**
  * Remove an event from the timeline
  *
  * @param {string} eventId  ID of event to be removed
  * @return {?MatrixEvent} removed event, or null if not found
  */
-EventTimeline.prototype.removeEvent = function(eventId) {
-    for (var i = this._events.length - 1; i >= 0; i--) {
-        var ev = this._events[i];
+EventTimeline.prototype.removeEvent = function (eventId) {
+    for (let i = this._events.length - 1; i >= 0; i--) {
+        const ev = this._events[i];
         if (ev.getId() == eventId) {
             this._events.splice(i, 1);
             if (i < this._baseIndex) {
                 this._baseIndex--;
             }
             return ev;
         }
     }
     return null;
 };
 
 /**
  * Return a string to identify this timeline, for debugging
  *
  * @return {string} name for this timeline
  */
-EventTimeline.prototype.toString = function() {
+EventTimeline.prototype.toString = function () {
     return this._name;
 };
 
-
 /**
  * The EventTimeline class
  */
-module.exports = EventTimeline;
+module.exports = EventTimeline;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/event.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/event.js
@@ -16,414 +16,1055 @@ limitations under the License.
 "use strict";
 
 /**
  * This is an internal module. See {@link MatrixEvent} and {@link RoomEvent} for
  * the public classes.
  * @module models/event
  */
 
-var EventEmitter = require("events").EventEmitter;
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _events = require('events');
+
+var _utils = require('../utils.js');
 
-var utils = require('../utils.js');
+var _utils2 = _interopRequireDefault(_utils);
+
+var _logger = require('../../src/logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
 /**
  * Enum for event statuses.
  * @readonly
  * @enum {string}
  */
-module.exports.EventStatus = {
+const EventStatus = {
     /** The event was not sent and will no longer be retried. */
     NOT_SENT: "not_sent",
 
     /** The message is being encrypted */
     ENCRYPTING: "encrypting",
 
     /** The event is in the process of being sent. */
     SENDING: "sending",
     /** The event is in a queue waiting to be sent. */
     QUEUED: "queued",
     /** The event has been sent to the server, but we have not yet received the
      * echo. */
     SENT: "sent",
 
     /** The event was cancelled before it was successfully sent. */
-    CANCELLED: "cancelled",
+    CANCELLED: "cancelled"
 };
+module.exports.EventStatus = EventStatus;
+
+const interns = {};
+function intern(str) {
+    if (!interns[str]) {
+        interns[str] = str;
+    }
+    return interns[str];
+}
 
 /**
  * Construct a Matrix Event object
  * @constructor
  *
  * @param {Object} event The raw event to be wrapped in this DAO
  *
  * @prop {Object} event The raw (possibly encrypted) event. <b>Do not access
  * this property</b> directly unless you absolutely have to. Prefer the getter
  * methods defined on this class. Using the getter methods shields your app
  * from changes to event JSON between Matrix versions.
  *
  * @prop {RoomMember} sender The room member who sent this event, or null e.g.
- * this is a presence event.
+ * this is a presence event. This is only guaranteed to be set for events that
+ * appear in a timeline, ie. do not guarantee that it will be set on state
+ * events.
  * @prop {RoomMember} target The room member who is the target of this event, e.g.
  * the invitee, the person being banned, etc.
  * @prop {EventStatus} status The sending status of the event.
+ * @prop {Error} error most recent error associated with sending the event, if any
  * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning
  * that getDirectionalContent() will return event.content and not event.prev_content.
  * Default: true. <strong>This property is experimental and may change.</strong>
  */
-module.exports.MatrixEvent = function MatrixEvent(
-    event
-) {
+module.exports.MatrixEvent = function MatrixEvent(event) {
+    // intern the values of matrix events to force share strings and reduce the
+    // amount of needless string duplication. This can save moderate amounts of
+    // memory (~10% on a 350MB heap).
+    // 'membership' at the event level (rather than the content level) is a legacy
+    // field that Riot never otherwise looks at, but it will still take up a lot
+    // of space if we don't intern it.
+    ["state_key", "type", "sender", "room_id", "membership"].forEach(prop => {
+        if (!event[prop]) {
+            return;
+        }
+        event[prop] = intern(event[prop]);
+    });
+
+    ["membership", "avatar_url", "displayname"].forEach(prop => {
+        if (!event.content || !event.content[prop]) {
+            return;
+        }
+        event.content[prop] = intern(event.content[prop]);
+    });
+
+    ["rel_type"].forEach(prop => {
+        if (!event.content || !event.content["m.relates_to"] || !event.content["m.relates_to"][prop]) {
+            return;
+        }
+        event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]);
+    });
+
     this.event = event || {};
+
     this.sender = null;
     this.target = null;
     this.status = null;
+    this.error = null;
     this.forwardLooking = true;
     this._pushActions = null;
+    this._replacingEvent = null;
+    this._localRedactionEvent = null;
+    this._isCancelled = false;
 
     this._clearEvent = {};
-    this._keysProved = {};
-    this._keysClaimed = {};
+
+    /* curve25519 key which we believe belongs to the sender of the event. See
+     * getSenderKey()
+     */
+    this._senderCurve25519Key = null;
+
+    /* ed25519 key which the sender of this event (for olm) or the creator of
+     * the megolm session (for megolm) claims to own. See getClaimedEd25519Key()
+     */
+    this._claimedEd25519Key = null;
+
+    /* curve25519 keys of devices involved in telling us about the
+     * _senderCurve25519Key and _claimedEd25519Key.
+     * See getForwardingCurve25519KeyChain().
+     */
+    this._forwardingCurve25519KeyChain = [];
+
+    /* if we have a process decrypting this event, a Promise which resolves
+     * when it is finished. Normally null.
+     */
+    this._decryptionPromise = null;
+
+    /* flag to indicate if we should retry decrypting this event after the
+     * first attempt (eg, we have received new data which means that a second
+     * attempt may succeed)
+     */
+    this._retryDecryption = false;
 };
-utils.inherits(module.exports.MatrixEvent, EventEmitter);
+_utils2.default.inherits(module.exports.MatrixEvent, _events.EventEmitter);
 
-
-utils.extend(module.exports.MatrixEvent.prototype, {
+_utils2.default.extend(module.exports.MatrixEvent.prototype, {
 
     /**
      * Get the event_id for this event.
      * @return {string} The event ID, e.g. <code>$143350589368169JsLZx:localhost
      * </code>
      */
-    getId: function() {
+    getId: function () {
         return this.event.event_id;
     },
 
     /**
      * Get the user_id for this event.
      * @return {string} The user ID, e.g. <code>@alice:matrix.org</code>
      */
-    getSender: function() {
+    getSender: function () {
         return this.event.sender || this.event.user_id; // v2 / v1
     },
 
     /**
      * Get the (decrypted, if necessary) type of event.
      *
      * @return {string} The event type, e.g. <code>m.room.message</code>
      */
-    getType: function() {
+    getType: function () {
         return this._clearEvent.type || this.event.type;
     },
 
     /**
      * Get the (possibly encrypted) type of the event that will be sent to the
      * homeserver.
      *
      * @return {string} The event type.
      */
-    getWireType: function() {
+    getWireType: function () {
         return this.event.type;
     },
 
     /**
      * Get the room_id for this event. This will return <code>undefined</code>
      * for <code>m.presence</code> events.
      * @return {string} The room ID, e.g. <code>!cURbafjkfsMDVwdRDQ:matrix.org
      * </code>
      */
-    getRoomId: function() {
+    getRoomId: function () {
         return this.event.room_id;
     },
 
     /**
      * Get the timestamp of this event.
      * @return {Number} The event timestamp, e.g. <code>1433502692297</code>
      */
-    getTs: function() {
+    getTs: function () {
         return this.event.origin_server_ts;
     },
 
     /**
-     * Get the (decrypted, if necessary) event content JSON.
+     * Get the timestamp of this event, as a Date object.
+     * @return {Date} The event date, e.g. <code>new Date(1433502692297)</code>
+     */
+    getDate: function () {
+        return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null;
+    },
+
+    /**
+     * Get the (decrypted, if necessary) event content JSON, even if the event
+     * was replaced by another event.
      *
      * @return {Object} The event content JSON, or an empty object.
      */
-    getContent: function() {
+    getOriginalContent: function () {
+        if (this._localRedactionEvent) {
+            return {};
+        }
         return this._clearEvent.content || this.event.content || {};
     },
 
     /**
+     * Get the (decrypted, if necessary) event content JSON,
+     * or the content from the replacing event, if any.
+     * See `makeReplaced`.
+     *
+     * @return {Object} The event content JSON, or an empty object.
+     */
+    getContent: function () {
+        if (this._localRedactionEvent) {
+            return {};
+        } else if (this._replacingEvent) {
+            return this._replacingEvent.getContent()["m.new_content"] || {};
+        } else {
+            return this.getOriginalContent();
+        }
+    },
+
+    /**
      * Get the (possibly encrypted) event content JSON that will be sent to the
      * homeserver.
      *
      * @return {Object} The event content JSON, or an empty object.
      */
-    getWireContent: function() {
+    getWireContent: function () {
         return this.event.content || {};
     },
 
     /**
      * Get the previous event content JSON. This will only return something for
      * state events which exist in the timeline.
      * @return {Object} The previous event content JSON, or an empty object.
      */
-    getPrevContent: function() {
+    getPrevContent: function () {
         // v2 then v1 then default
         return this.getUnsigned().prev_content || this.event.prev_content || {};
     },
 
     /**
      * Get either 'content' or 'prev_content' depending on if this event is
      * 'forward-looking' or not. This can be modified via event.forwardLooking.
      * In practice, this means we get the chronologically earlier content value
      * for this event (this method should surely be called getEarlierContent)
      * <strong>This method is experimental and may change.</strong>
      * @return {Object} event.content if this event is forward-looking, else
      * event.prev_content.
      */
-    getDirectionalContent: function() {
+    getDirectionalContent: function () {
         return this.forwardLooking ? this.getContent() : this.getPrevContent();
     },
 
     /**
      * Get the age of this event. This represents the age of the event when the
      * event arrived at the device, and not the age of the event when this
      * function was called.
      * @return {Number} The age of this event in milliseconds.
      */
-    getAge: function() {
+    getAge: function () {
         return this.getUnsigned().age || this.event.age; // v2 / v1
     },
 
     /**
      * Get the event state_key if it has one. This will return <code>undefined
      * </code> for message events.
      * @return {string} The event's <code>state_key</code>.
      */
-    getStateKey: function() {
+    getStateKey: function () {
         return this.event.state_key;
     },
 
     /**
      * Check if this event is a state event.
      * @return {boolean} True if this is a state event.
      */
-    isState: function() {
+    isState: function () {
         return this.event.state_key !== undefined;
     },
 
     /**
      * Replace the content of this event with encrypted versions.
      * (This is used when sending an event; it should not be used by applications).
      *
      * @internal
      *
      * @param {string} crypto_type type of the encrypted event - typically
      * <tt>"m.room.encrypted"</tt>
      *
      * @param {object} crypto_content raw 'content' for the encrypted event.
-     * @param {object} keys The local keys claimed and proved by this event.
+     *
+     * @param {string} senderCurve25519Key curve25519 key to record for the
+     *   sender of this event.
+     *   See {@link module:models/event.MatrixEvent#getSenderKey}.
+     *
+     * @param {string} claimedEd25519Key claimed ed25519 key to record for the
+     *   sender if this event.
+     *   See {@link module:models/event.MatrixEvent#getClaimedEd25519Key}
      */
-    makeEncrypted: function(crypto_type, crypto_content, keys) {
+    makeEncrypted: function (crypto_type, crypto_content, senderCurve25519Key, claimedEd25519Key) {
         // keep the plain-text data for 'view source'
         this._clearEvent = {
             type: this.event.type,
-            content: this.event.content,
+            content: this.event.content
         };
         this.event.type = crypto_type;
         this.event.content = crypto_content;
-        this._keysProved = keys;
-        this._keysClaimed = keys;
+        this._senderCurve25519Key = senderCurve25519Key;
+        this._claimedEd25519Key = claimedEd25519Key;
+    },
+
+    /**
+     * Check if this event is currently being decrypted.
+     *
+     * @return {boolean} True if this event is currently being decrypted, else false.
+     */
+    isBeingDecrypted: function () {
+        return this._decryptionPromise != null;
+    },
+
+    /**
+     * Check if this event is an encrypted event which we failed to decrypt
+     *
+     * (This implies that we might retry decryption at some point in the future)
+     *
+     * @return {boolean} True if this event is an encrypted event which we
+     *     couldn't decrypt.
+     */
+    isDecryptionFailure: function () {
+        return this._clearEvent && this._clearEvent.content && this._clearEvent.content.msgtype === "m.bad.encrypted";
+    },
+
+    /**
+     * Start the process of trying to decrypt this event.
+     *
+     * (This is used within the SDK: it isn't intended for use by applications)
+     *
+     * @internal
+     *
+     * @param {module:crypto} crypto crypto module
+     *
+     * @returns {Promise} promise which resolves (to undefined) when the decryption
+     * attempt is completed.
+     */
+    attemptDecryption: async function (crypto) {
+        // start with a couple of sanity checks.
+        if (!this.isEncrypted()) {
+            throw new Error("Attempt to decrypt event which isn't encrypted");
+        }
+
+        if (this._clearEvent && this._clearEvent.content && this._clearEvent.content.msgtype !== "m.bad.encrypted") {
+            // we may want to just ignore this? let's start with rejecting it.
+            throw new Error("Attempt to decrypt event which has already been encrypted");
+        }
+
+        // if we already have a decryption attempt in progress, then it may
+        // fail because it was using outdated info. We now have reason to
+        // succeed where it failed before, but we don't want to have multiple
+        // attempts going at the same time, so just set a flag that says we have
+        // new info.
+        //
+        if (this._decryptionPromise) {
+            _logger2.default.log(`Event ${this.getId()} already being decrypted; queueing a retry`);
+            this._retryDecryption = true;
+            return this._decryptionPromise;
+        }
+
+        this._decryptionPromise = this._decryptionLoop(crypto);
+        return this._decryptionPromise;
+    },
+
+    /**
+     * Cancel any room key request for this event and resend another.
+     *
+     * @param {module:crypto} crypto crypto module
+     * @param {string} userId the user who received this event
+     *
+     * @returns {Promise} a promise that resolves when the request is queued
+     */
+    cancelAndResendKeyRequest: function (crypto, userId) {
+        const wireContent = this.getWireContent();
+        return crypto.requestRoomKey({
+            algorithm: wireContent.algorithm,
+            room_id: this.getRoomId(),
+            session_id: wireContent.session_id,
+            sender_key: wireContent.sender_key
+        }, this.getKeyRequestRecipients(userId), true);
+    },
+
+    /**
+     * Calculate the recipients for keyshare requests.
+     *
+     * @param {string} userId the user who received this event.
+     *
+     * @returns {Array} array of recipients
+     */
+    getKeyRequestRecipients: function (userId) {
+        // send the request to all of our own devices, and the
+        // original sending device if it wasn't us.
+        const wireContent = this.getWireContent();
+        const recipients = [{
+            userId, deviceId: '*'
+        }];
+        const sender = this.getSender();
+        if (sender !== userId) {
+            recipients.push({
+                userId: sender, deviceId: wireContent.device_id
+            });
+        }
+        return recipients;
+    },
+
+    _decryptionLoop: async function (crypto) {
+        // make sure that this method never runs completely synchronously.
+        // (doing so would mean that we would clear _decryptionPromise *before*
+        // it is set in attemptDecryption - and hence end up with a stuck
+        // `_decryptionPromise`).
+        await _bluebird2.default.resolve();
+
+        while (true) {
+            this._retryDecryption = false;
+
+            let res;
+            let err;
+            try {
+                if (!crypto) {
+                    res = this._badEncryptedMessage("Encryption not enabled");
+                } else {
+                    res = await crypto.decryptEvent(this);
+                }
+            } catch (e) {
+                if (e.name !== "DecryptionError") {
+                    // not a decryption error: log the whole exception as an error
+                    // (and don't bother with a retry)
+                    _logger2.default.error(`Error decrypting event (id=${this.getId()}): ${e.stack || e}`);
+                    this._decryptionPromise = null;
+                    this._retryDecryption = false;
+                    return;
+                }
+
+                err = e;
+
+                // see if we have a retry queued.
+                //
+                // NB: make sure to keep this check in the same tick of the
+                //   event loop as `_decryptionPromise = null` below - otherwise we
+                //   risk a race:
+                //
+                //   * A: we check _retryDecryption here and see that it is
+                //        false
+                //   * B: we get a second call to attemptDecryption, which sees
+                //        that _decryptionPromise is set so sets
+                //        _retryDecryption
+                //   * A: we continue below, clear _decryptionPromise, and
+                //        never do the retry.
+                //
+                if (this._retryDecryption) {
+                    // decryption error, but we have a retry queued.
+                    _logger2.default.log(`Got error decrypting event (id=${this.getId()}: ` + `${e}), but retrying`);
+                    continue;
+                }
+
+                // decryption error, no retries queued. Warn about the error and
+                // set it to m.bad.encrypted.
+                _logger2.default.warn(`Error decrypting event (id=${this.getId()}): ${e.detailedString}`);
+
+                res = this._badEncryptedMessage(e.message);
+            }
+
+            // at this point, we've either successfully decrypted the event, or have given up
+            // (and set res to a 'badEncryptedMessage'). Either way, we can now set the
+            // cleartext of the event and raise Event.decrypted.
+            //
+            // make sure we clear '_decryptionPromise' before sending the 'Event.decrypted' event,
+            // otherwise the app will be confused to see `isBeingDecrypted` still set when
+            // there isn't an `Event.decrypted` on the way.
+            //
+            // see also notes on _retryDecryption above.
+            //
+            this._decryptionPromise = null;
+            this._retryDecryption = false;
+            this._setClearData(res);
+
+            // Before we emit the event, clear the push actions so that they can be recalculated
+            // by relevant code. We do this because the clear event has now changed, making it
+            // so that existing rules can be re-run over the applicable properties. Stuff like
+            // highlighting when the user's name is mentioned rely on this happening. We also want
+            // to set the push actions before emitting so that any notification listeners don't
+            // pick up the wrong contents.
+            this.setPushActions(null);
+
+            this.emit("Event.decrypted", this, err);
+
+            return;
+        }
+    },
+
+    _badEncryptedMessage: function (reason) {
+        return {
+            clearEvent: {
+                type: "m.room.message",
+                content: {
+                    msgtype: "m.bad.encrypted",
+                    body: "** Unable to decrypt: " + reason + " **"
+                }
+            }
+        };
     },
 
     /**
      * Update the cleartext data on this event.
      *
      * (This is used after decrypting an event; it should not be used by applications).
      *
      * @internal
      *
      * @fires module:models/event.MatrixEvent#"Event.decrypted"
      *
-     * @param {Object} clearEvent The plaintext payload for the event
-     *     (typically containing <tt>type</tt> and <tt>content</tt> fields).
-     *
-     * @param {Object=} keysProved Keys owned by the sender of this event.
-     *    See {@link module:models/event.MatrixEvent#getKeysProved}.
+     * @param {module:crypto~EventDecryptionResult} decryptionResult
+     *     the decryption result, including the plaintext and some key info
+     */
+    _setClearData: function (decryptionResult) {
+        this._clearEvent = decryptionResult.clearEvent;
+        this._senderCurve25519Key = decryptionResult.senderCurve25519Key || null;
+        this._claimedEd25519Key = decryptionResult.claimedEd25519Key || null;
+        this._forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
+    },
+
+    /**
+     * Gets the cleartext content for this event. If the event is not encrypted,
+     * or encryption has not been completed, this will return null.
      *
-     * @param {Object=} keysClaimed Keys the sender of this event claims.
-     *    See {@link module:models/event.MatrixEvent#getKeysClaimed}.
+     * @returns {Object} The cleartext (decrypted) content for the event
      */
-    setClearData: function(clearEvent, keysProved, keysClaimed) {
-        this._clearEvent = clearEvent;
-        this._keysProved = keysProved || {};
-        this._keysClaimed = keysClaimed || {};
-        this.emit("Event.decrypted", this);
+    getClearContent: function () {
+        const ev = this._clearEvent;
+        return ev && ev.content ? ev.content : null;
     },
 
     /**
      * Check if the event is encrypted.
      * @return {boolean} True if this event is encrypted.
      */
-    isEncrypted: function() {
+    isEncrypted: function () {
         return this.event.type === "m.room.encrypted";
     },
 
     /**
-     * The curve25519 key that sent this event
+     * The curve25519 key for the device that we think sent this event
+     *
+     * For an Olm-encrypted event, this is inferred directly from the DH
+     * exchange at the start of the session: the curve25519 key is involved in
+     * the DH exchange, so only a device which holds the private part of that
+     * key can establish such a session.
+     *
+     * For a megolm-encrypted event, it is inferred from the Olm message which
+     * established the megolm session
+     *
      * @return {string}
      */
-    getSenderKey: function() {
-        return this.getKeysProved().curve25519 || null;
-    },
-
-    /**
-     * The keys that must have been owned by the sender of this encrypted event.
-     * <p>
-     * These don't necessarily have to come from this event itself, but may be
-     * implied by the cryptographic session.
-     *
-     * @return {Object<string, string>}
-     */
-    getKeysProved: function() {
-        return this._keysProved;
+    getSenderKey: function () {
+        return this._senderCurve25519Key;
     },
 
     /**
      * The additional keys the sender of this encrypted event claims to possess.
-     * <p>
-     * These don't necessarily have to come from this event itself, but may be
-     * implied by the cryptographic session.
-     * For example megolm messages don't claim keys directly, but instead
-     * inherit a claim from the olm message that established the session.
+     *
+     * Just a wrapper for #getClaimedEd25519Key (q.v.)
      *
      * @return {Object<string, string>}
      */
-    getKeysClaimed: function() {
-        return this._keysClaimed;
+    getKeysClaimed: function () {
+        return {
+            ed25519: this._claimedEd25519Key
+        };
+    },
+
+    /**
+     * Get the ed25519 the sender of this event claims to own.
+     *
+     * For Olm messages, this claim is encoded directly in the plaintext of the
+     * event itself. For megolm messages, it is implied by the m.room_key event
+     * which established the megolm session.
+     *
+     * Until we download the device list of the sender, it's just a claim: the
+     * device list gives a proof that the owner of the curve25519 key used for
+     * this event (and returned by #getSenderKey) also owns the ed25519 key by
+     * signing the public curve25519 key with the ed25519 key.
+     *
+     * In general, applications should not use this method directly, but should
+     * instead use MatrixClient.getEventSenderDeviceInfo.
+     *
+     * @return {string}
+     */
+    getClaimedEd25519Key: function () {
+        return this._claimedEd25519Key;
     },
 
-    getUnsigned: function() {
+    /**
+     * Get the curve25519 keys of the devices which were involved in telling us
+     * about the claimedEd25519Key and sender curve25519 key.
+     *
+     * Normally this will be empty, but in the case of a forwarded megolm
+     * session, the sender keys are sent to us by another device (the forwarding
+     * device), which we need to trust to do this. In that case, the result will
+     * be a list consisting of one entry.
+     *
+     * If the device that sent us the key (A) got it from another device which
+     * it wasn't prepared to vouch for (B), the result will be [A, B]. And so on.
+     *
+     * @return {string[]} base64-encoded curve25519 keys, from oldest to newest.
+     */
+    getForwardingCurve25519KeyChain: function () {
+        return this._forwardingCurve25519KeyChain;
+    },
+
+    getUnsigned: function () {
         return this.event.unsigned || {};
     },
 
+    unmarkLocallyRedacted: function () {
+        const value = this._localRedactionEvent;
+        this._localRedactionEvent = null;
+        if (this.event.unsigned) {
+            this.event.unsigned.redacted_because = null;
+        }
+        return !!value;
+    },
+
+    markLocallyRedacted: function (redactionEvent) {
+        if (this._localRedactionEvent) {
+            return;
+        }
+        this.emit("Event.beforeRedaction", this, redactionEvent);
+        this._localRedactionEvent = redactionEvent;
+        if (!this.event.unsigned) {
+            this.event.unsigned = {};
+        }
+        this.event.unsigned.redacted_because = redactionEvent.event;
+    },
+
     /**
      * Update the content of an event in the same way it would be by the server
      * if it were redacted before it was sent to us
      *
      * @param {module:models/event.MatrixEvent} redaction_event
      *     event causing the redaction
      */
-    makeRedacted: function(redaction_event) {
+    makeRedacted: function (redaction_event) {
         // quick sanity-check
         if (!redaction_event.event) {
             throw new Error("invalid redaction_event in makeRedacted");
         }
 
+        this._localRedactionEvent = null;
+
+        this.emit("Event.beforeRedaction", this, redaction_event);
+
+        this._replacingEvent = null;
         // we attempt to replicate what we would see from the server if
         // the event had been redacted before we saw it.
         //
         // The server removes (most of) the content of the event, and adds a
         // "redacted_because" key to the unsigned section containing the
         // redacted event.
         if (!this.event.unsigned) {
             this.event.unsigned = {};
         }
         this.event.unsigned.redacted_because = redaction_event.event;
 
-        var key;
+        let key;
         for (key in this.event) {
-            if (!this.event.hasOwnProperty(key)) { continue; }
+            if (!this.event.hasOwnProperty(key)) {
+                continue;
+            }
             if (!_REDACT_KEEP_KEY_MAP[key]) {
                 delete this.event[key];
             }
         }
 
-        var keeps = _REDACT_KEEP_CONTENT_MAP[this.getType()] || {};
-        var content = this.getContent();
+        const keeps = _REDACT_KEEP_CONTENT_MAP[this.getType()] || {};
+        const content = this.getContent();
         for (key in content) {
-            if (!content.hasOwnProperty(key)) { continue; }
+            if (!content.hasOwnProperty(key)) {
+                continue;
+            }
             if (!keeps[key]) {
                 delete content[key];
             }
         }
     },
 
     /**
      * Check if this event has been redacted
      *
      * @return {boolean} True if this event has been redacted
      */
-    isRedacted: function() {
+    isRedacted: function () {
         return Boolean(this.getUnsigned().redacted_because);
     },
 
     /**
+     * Check if this event is a redaction of another event
+     *
+     * @return {boolean} True if this event is a redaction
+     */
+    isRedaction: function () {
+        return this.getType() === "m.room.redaction";
+    },
+
+    /**
      * Get the push actions, if known, for this event
      *
      * @return {?Object} push actions
      */
-     getPushActions: function() {
+    getPushActions: function () {
         return this._pushActions;
-     },
+    },
 
     /**
      * Set the push actions for this event.
      *
      * @param {Object} pushActions push actions
      */
-     setPushActions: function(pushActions) {
+    setPushActions: function (pushActions) {
         this._pushActions = pushActions;
-     },
+    },
+
+    /**
+     * Replace the `event` property and recalculate any properties based on it.
+     * @param {Object} event the object to assign to the `event` property
+     */
+    handleRemoteEcho: function (event) {
+        const oldUnsigned = this.getUnsigned();
+        const oldId = this.getId();
+        this.event = event;
+        // if this event was redacted before it was sent, it's locally marked as redacted.
+        // At this point, we've received the remote echo for the event, but not yet for
+        // the redaction that we are sending ourselves. Preserve the locally redacted
+        // state by copying over redacted_because so we don't get a flash of
+        // redacted, not-redacted, redacted as remote echos come in
+        if (oldUnsigned.redacted_because) {
+            if (!this.event.unsigned) {
+                this.event.unsigned = {};
+            }
+            this.event.unsigned.redacted_because = oldUnsigned.redacted_because;
+        }
+        // successfully sent.
+        this.setStatus(null);
+        if (this.getId() !== oldId) {
+            // emit the event if it changed
+            this.emit("Event.localEventIdReplaced", this);
+        }
+    },
+
+    /**
+     * Whether the event is in any phase of sending, send failure, waiting for
+     * remote echo, etc.
+     *
+     * @return {boolean}
+     */
+    isSending() {
+        return !!this.status;
+    },
+
+    /**
+     * Update the event's sending status and emit an event as well.
+     *
+     * @param {String} status The new status
+     */
+    setStatus(status) {
+        this.status = status;
+        this.emit("Event.status", this, status);
+    },
+
+    replaceLocalEventId(eventId) {
+        this.event.event_id = eventId;
+        this.emit("Event.localEventIdReplaced", this);
+    },
+
+    /**
+     * Get whether the event is a relation event, and of a given type if
+     * `relType` is passed in.
+     *
+     * @param {string?} relType if given, checks that the relation is of the
+     * given type
+     * @return {boolean}
+     */
+    isRelation(relType = undefined) {
+        // Relation info is lifted out of the encrypted content when sent to
+        // encrypted rooms, so we have to check `getWireContent` for this.
+        const content = this.getWireContent();
+        const relation = content && content["m.relates_to"];
+        return relation && relation.rel_type && relation.event_id && (relType && relation.rel_type === relType || !relType);
+    },
+
+    /**
+     * Get relation info for the event, if any.
+     *
+     * @return {Object}
+     */
+    getRelation() {
+        if (!this.isRelation()) {
+            return null;
+        }
+        return this.getWireContent()["m.relates_to"];
+    },
+
+    /**
+     * Set an event that replaces the content of this event, through an m.replace relation.
+     *
+     * @param {MatrixEvent?} newEvent the event with the replacing content, if any.
+     */
+    makeReplaced(newEvent) {
+        // don't allow redacted events to be replaced.
+        // if newEvent is null we allow to go through though,
+        // as with local redaction, the replacing event might get
+        // cancelled, which should be reflected on the target event.
+        if (this.isRedacted() && newEvent) {
+            return;
+        }
+        if (this._replacingEvent !== newEvent) {
+            this._replacingEvent = newEvent;
+            this.emit("Event.replaced", this);
+        }
+    },
+
+    /**
+     * Returns the status of any associated edit or redaction
+     * (not for reactions/annotations as their local echo doesn't affect the orignal event),
+     * or else the status of the event.
+     *
+     * @return {EventStatus}
+     */
+    getAssociatedStatus() {
+        if (this._replacingEvent) {
+            return this._replacingEvent.status;
+        } else if (this._localRedactionEvent) {
+            return this._localRedactionEvent.status;
+        }
+        return this.status;
+    },
+
+    getServerAggregatedRelation(relType) {
+        const relations = this.getUnsigned()["m.relations"];
+        if (relations) {
+            return relations[relType];
+        }
+    },
+
+    /**
+     * Returns the event ID of the event replacing the content of this event, if any.
+     *
+     * @return {string?}
+     */
+    replacingEventId() {
+        const replaceRelation = this.getServerAggregatedRelation("m.replace");
+        if (replaceRelation) {
+            return replaceRelation.event_id;
+        } else if (this._replacingEvent) {
+            return this._replacingEvent.getId();
+        }
+    },
+
+    /**
+     * Returns the event replacing the content of this event, if any.
+     * Replacements are aggregated on the server, so this would only
+     * return an event in case it came down the sync, or for local echo of edits.
+     *
+     * @return {MatrixEvent?}
+     */
+    replacingEvent() {
+        return this._replacingEvent;
+    },
+
+    /**
+     * Returns the origin_server_ts of the event replacing the content of this event, if any.
+     *
+     * @return {Date?}
+     */
+    replacingEventDate() {
+        const replaceRelation = this.getServerAggregatedRelation("m.replace");
+        if (replaceRelation) {
+            const ts = replaceRelation.origin_server_ts;
+            if (Number.isFinite(ts)) {
+                return new Date(ts);
+            }
+        } else if (this._replacingEvent) {
+            return this._replacingEvent.getDate();
+        }
+    },
+
+    /**
+     * Returns the event that wants to redact this event, but hasn't been sent yet.
+     * @return {MatrixEvent} the event
+     */
+    localRedactionEvent() {
+        return this._localRedactionEvent;
+    },
+
+    /**
+     * For relations and redactions, returns the event_id this event is referring to.
+     *
+     * @return {string?}
+     */
+    getAssociatedId() {
+        const relation = this.getRelation();
+        if (relation) {
+            return relation.event_id;
+        } else if (this.isRedaction()) {
+            return this.event.redacts;
+        }
+    },
+
+    /**
+     * Checks if this event is associated with another event. See `getAssociatedId`.
+     *
+     * @return {bool}
+     */
+    hasAssocation() {
+        return !!this.getAssociatedId();
+    },
+
+    /**
+     * Update the related id with a new one.
+     *
+     * Used to replace a local id with remote one before sending
+     * an event with a related id.
+     *
+     * @param {string} eventId the new event id
+     */
+    updateAssociatedId(eventId) {
+        const relation = this.getRelation();
+        if (relation) {
+            relation.event_id = eventId;
+        } else if (this.isRedaction()) {
+            this.event.redacts = eventId;
+        }
+    },
+
+    /**
+     * Flags an event as cancelled due to future conditions. For example, a verification
+     * request event in the same sync transaction may be flagged as cancelled to warn
+     * listeners that a cancellation event is coming down the same pipe shortly.
+     * @param {boolean} cancelled Whether the event is to be cancelled or not.
+     */
+    flagCancelled(cancelled = true) {
+        this._isCancelled = cancelled;
+    },
+
+    /**
+     * Gets whether or not the event is flagged as cancelled. See flagCancelled() for
+     * more information.
+     * @returns {boolean} True if the event is cancelled, false otherwise.
+     */
+    isCancelled() {
+        return this._isCancelled;
+    },
+
+    /**
+     * Summarise the event as JSON for debugging. If encrypted, include both the
+     * decrypted and encrypted view of the event. This is named `toJSON` for use
+     * with `JSON.stringify` which checks objects for functions named `toJSON`
+     * and will call them to customise the output if they are defined.
+     *
+     * @return {Object}
+     */
+    toJSON() {
+        const event = {
+            type: this.getType(),
+            sender: this.getSender(),
+            content: this.getContent(),
+            event_id: this.getId(),
+            origin_server_ts: this.getTs(),
+            unsigned: this.getUnsigned(),
+            room_id: this.getRoomId()
+        };
+
+        // if this is a redaction then attach the redacts key
+        if (this.isRedaction()) {
+            event.redacts = this.event.redacts;
+        }
+
+        if (!this.isEncrypted()) {
+            return event;
+        }
+
+        return {
+            decrypted: event,
+            encrypted: this.event
+        };
+    }
 });
 
-
-/* http://matrix.org/docs/spec/r0.0.1/client_server.html#redactions says:
+/* _REDACT_KEEP_KEY_MAP gives the keys we keep when an event is redacted
  *
- * the server should strip off any keys not in the following list:
- *    event_id
- *    type
- *    room_id
- *    user_id
- *    state_key
- *    prev_state
- *    content
- *    [we keep 'unsigned' as well, since that is created by the local server]
+ * This is specified here:
+ *  http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions
  *
- * The content object should also be stripped of all keys, unless it is one of
- * one of the following event types:
- *    m.room.member allows key membership
- *    m.room.create allows key creator
- *    m.room.join_rules allows key join_rule
- *    m.room.power_levels allows keys ban, events, events_default, kick,
- *        redact, state_default, users, users_default.
- *    m.room.aliases allows key aliases
+ * Also:
+ *  - We keep 'unsigned' since that is created by the local server
+ *  - We keep user_id for backwards-compat with v1
  */
-// a map giving the keys we keep when an event is redacted
-var _REDACT_KEEP_KEY_MAP = [
-    'event_id', 'type', 'room_id', 'user_id', 'state_key', 'prev_state',
-    'content', 'unsigned',
-].reduce(function(ret, val) { ret[val] = 1; return ret; }, {});
+const _REDACT_KEEP_KEY_MAP = ['event_id', 'type', 'room_id', 'user_id', 'sender', 'state_key', 'prev_state', 'content', 'unsigned', 'origin_server_ts'].reduce(function (ret, val) {
+    ret[val] = 1;return ret;
+}, {});
 
 // a map from event type to the .content keys we keep when an event is redacted
-var _REDACT_KEEP_CONTENT_MAP = {
-    'm.room.member': {'membership': 1},
-    'm.room.create': {'creator': 1},
-    'm.room.join_rules': {'join_rule': 1},
-    'm.room.power_levels': {'ban': 1, 'events': 1, 'events_default': 1,
-                            'kick': 1, 'redact': 1, 'state_default': 1,
-                            'users': 1, 'users_default': 1,
-                           },
-    'm.room.aliases': {'aliases': 1},
+const _REDACT_KEEP_CONTENT_MAP = {
+    'm.room.member': { 'membership': 1 },
+    'm.room.create': { 'creator': 1 },
+    'm.room.join_rules': { 'join_rule': 1 },
+    'm.room.power_levels': { 'ban': 1, 'events': 1, 'events_default': 1,
+        'kick': 1, 'redact': 1, 'state_default': 1,
+        'users': 1, 'users_default': 1
+    },
+    'm.room.aliases': { 'aliases': 1 }
 };
 
-
-
-
 /**
  * Fires when an event is decrypted
  *
  * @event module:models/event.MatrixEvent#"Event.decrypted"
  *
  * @param {module:models/event.MatrixEvent} event
  *    The matrix event which has been decrypted
- */
+ * @param {module:crypto/algorithms/base.DecryptionError?} err
+ *    The error that occured during decryption, or `undefined` if no
+ *    error occured.
+ */
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/group.js
@@ -0,0 +1,97 @@
+"use strict";
+
+/*
+Copyright 2017 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * @module models/group
+ */
+const EventEmitter = require("events").EventEmitter;
+
+const utils = require("../utils");
+
+/**
+ * Construct a new Group.
+ *
+ * @param {string} groupId The ID of this group.
+ *
+ * @prop {string} groupId The ID of this group.
+ * @prop {string} name The human-readable display name for this group.
+ * @prop {string} avatarUrl The mxc URL for this group's avatar.
+ * @prop {string} myMembership The logged in user's membership of this group
+ * @prop {Object} inviter Infomation about the user who invited the logged in user
+ *       to the group, if myMembership is 'invite'.
+ * @prop {string} inviter.userId The user ID of the inviter
+ */
+function Group(groupId) {
+    this.groupId = groupId;
+    this.name = null;
+    this.avatarUrl = null;
+    this.myMembership = null;
+    this.inviter = null;
+}
+utils.inherits(Group, EventEmitter);
+
+Group.prototype.setProfile = function (name, avatarUrl) {
+    if (this.name === name && this.avatarUrl === avatarUrl) return;
+
+    this.name = name || this.groupId;
+    this.avatarUrl = avatarUrl;
+
+    this.emit("Group.profile", this);
+};
+
+Group.prototype.setMyMembership = function (membership) {
+    if (this.myMembership === membership) return;
+
+    this.myMembership = membership;
+
+    this.emit("Group.myMembership", this);
+};
+
+/**
+ * Sets the 'inviter' property. This does not emit an event (the inviter
+ * will only change when the user is revited / reinvited to a room),
+ * so set this before setting myMembership.
+ * @param {Object} inviter Infomation about who invited us to the room
+ */
+Group.prototype.setInviter = function (inviter) {
+    this.inviter = inviter;
+};
+
+module.exports = Group;
+
+/**
+ * Fires whenever a group's profile information is updated.
+ * This means the 'name' and 'avatarUrl' properties.
+ * @event module:client~MatrixClient#"Group.profile"
+ * @param {Group} group The group whose profile was updated.
+ * @example
+ * matrixClient.on("Group.profile", function(group){
+ *   var name = group.name;
+ * });
+ */
+
+/**
+ * Fires whenever the logged in user's membership status of
+ * the group is updated.
+ * @event module:client~MatrixClient#"Group.myMembership"
+ * @param {Group} group The group in which the user's membership changed
+ * @example
+ * matrixClient.on("Group.myMembership", function(group){
+ *   var myMembership = group.myMembership;
+ * });
+ */
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/relations.js
@@ -0,0 +1,358 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _events = require('events');
+
+var _events2 = _interopRequireDefault(_events);
+
+var _event = require('../../lib/models/event');
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * A container for relation events that supports easy access to common ways of
+ * aggregating such events. Each instance holds events that of a single relation
+ * type and event type. All of the events also relate to the same original event.
+ *
+ * The typical way to get one of these containers is via
+ * EventTimelineSet#getRelationsForEvent.
+ */
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+class Relations extends _events2.default {
+    /**
+     * @param {String} relationType
+     * The type of relation involved, such as "m.annotation", "m.reference",
+     * "m.replace", etc.
+     * @param {String} eventType
+     * The relation event's type, such as "m.reaction", etc.
+     * @param {?Room} room
+     * Room for this container. May be null for non-room cases, such as the
+     * notification timeline.
+     */
+    constructor(relationType, eventType, room) {
+        super();
+
+        this._onEventStatus = (event, status) => {
+            if (!event.isSending()) {
+                // Sending is done, so we don't need to listen anymore
+                event.removeListener("Event.status", this._onEventStatus);
+                return;
+            }
+            if (status !== _event.EventStatus.CANCELLED) {
+                return;
+            }
+            // Event was cancelled, remove from the collection
+            event.removeListener("Event.status", this._onEventStatus);
+            this._removeEvent(event);
+        };
+
+        this._onBeforeRedaction = redactedEvent => {
+            if (!this._relations.has(redactedEvent)) {
+                return;
+            }
+
+            this._relations.delete(redactedEvent);
+
+            if (this.relationType === "m.annotation") {
+                // Remove the redacted annotation from aggregation by key
+                this._removeAnnotationFromAggregation(redactedEvent);
+            } else if (this.relationType === "m.replace" && this._targetEvent) {
+                this._targetEvent.makeReplaced(this.getLastReplacement());
+            }
+
+            redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction);
+
+            this.emit("Relations.redaction");
+        };
+
+        this.relationType = relationType;
+        this.eventType = eventType;
+        this._relations = new Set();
+        this._annotationsByKey = {};
+        this._annotationsBySender = {};
+        this._sortedAnnotationsByKey = [];
+        this._targetEvent = null;
+    }
+
+    /**
+     * Add relation events to this collection.
+     *
+     * @param {MatrixEvent} event
+     * The new relation event to be added.
+     */
+    addEvent(event) {
+        if (this._relations.has(event)) {
+            return;
+        }
+
+        const relation = event.getRelation();
+        if (!relation) {
+            console.error("Event must have relation info");
+            return;
+        }
+
+        const relationType = relation.rel_type;
+        const eventType = event.getType();
+
+        if (this.relationType !== relationType || this.eventType !== eventType) {
+            console.error("Event relation info doesn't match this container");
+            return;
+        }
+
+        // If the event is in the process of being sent, listen for cancellation
+        // so we can remove the event from the collection.
+        if (event.isSending()) {
+            event.on("Event.status", this._onEventStatus);
+        }
+
+        this._relations.add(event);
+
+        if (this.relationType === "m.annotation") {
+            this._addAnnotationToAggregation(event);
+        } else if (this.relationType === "m.replace" && this._targetEvent) {
+            this._targetEvent.makeReplaced(this.getLastReplacement());
+        }
+
+        event.on("Event.beforeRedaction", this._onBeforeRedaction);
+
+        this.emit("Relations.add", event);
+    }
+
+    /**
+     * Remove relation event from this collection.
+     *
+     * @param {MatrixEvent} event
+     * The relation event to remove.
+     */
+    _removeEvent(event) {
+        if (!this._relations.has(event)) {
+            return;
+        }
+
+        const relation = event.getRelation();
+        if (!relation) {
+            console.error("Event must have relation info");
+            return;
+        }
+
+        const relationType = relation.rel_type;
+        const eventType = event.getType();
+
+        if (this.relationType !== relationType || this.eventType !== eventType) {
+            console.error("Event relation info doesn't match this container");
+            return;
+        }
+
+        this._relations.delete(event);
+
+        if (this.relationType === "m.annotation") {
+            this._removeAnnotationFromAggregation(event);
+        } else if (this.relationType === "m.replace" && this._targetEvent) {
+            this._targetEvent.makeReplaced(this.getLastReplacement());
+        }
+
+        this.emit("Relations.remove", event);
+    }
+
+    /**
+     * Listens for event status changes to remove cancelled events.
+     *
+     * @param {MatrixEvent} event The event whose status has changed
+     * @param {EventStatus} status The new status
+     */
+
+
+    /**
+     * Get all relation events in this collection.
+     *
+     * These are currently in the order of insertion to this collection, which
+     * won't match timeline order in the case of scrollback.
+     * TODO: Tweak `addEvent` to insert correctly for scrollback.
+     *
+     * @return {Array}
+     * Relation events in insertion order.
+     */
+    getRelations() {
+        return [...this._relations];
+    }
+
+    _addAnnotationToAggregation(event) {
+        const { key } = event.getRelation();
+        if (!key) {
+            return;
+        }
+
+        let eventsForKey = this._annotationsByKey[key];
+        if (!eventsForKey) {
+            eventsForKey = this._annotationsByKey[key] = new Set();
+            this._sortedAnnotationsByKey.push([key, eventsForKey]);
+        }
+        // Add the new event to the set for this key
+        eventsForKey.add(event);
+        // Re-sort the [key, events] pairs in descending order of event count
+        this._sortedAnnotationsByKey.sort((a, b) => {
+            const aEvents = a[1];
+            const bEvents = b[1];
+            return bEvents.size - aEvents.size;
+        });
+
+        const sender = event.getSender();
+        let eventsFromSender = this._annotationsBySender[sender];
+        if (!eventsFromSender) {
+            eventsFromSender = this._annotationsBySender[sender] = new Set();
+        }
+        // Add the new event to the set for this sender
+        eventsFromSender.add(event);
+    }
+
+    _removeAnnotationFromAggregation(event) {
+        const { key } = event.getRelation();
+        if (!key) {
+            return;
+        }
+
+        const eventsForKey = this._annotationsByKey[key];
+        if (eventsForKey) {
+            eventsForKey.delete(event);
+
+            // Re-sort the [key, events] pairs in descending order of event count
+            this._sortedAnnotationsByKey.sort((a, b) => {
+                const aEvents = a[1];
+                const bEvents = b[1];
+                return bEvents.size - aEvents.size;
+            });
+        }
+
+        const sender = event.getSender();
+        const eventsFromSender = this._annotationsBySender[sender];
+        if (eventsFromSender) {
+            eventsFromSender.delete(event);
+        }
+    }
+
+    /**
+     * For relations that have been redacted, we want to remove them from
+     * aggregation data sets and emit an update event.
+     *
+     * To do so, we listen for `Event.beforeRedaction`, which happens:
+     *   - after the server accepted the redaction and remote echoed back to us
+     *   - before the original event has been marked redacted in the client
+     *
+     * @param {MatrixEvent} redactedEvent
+     * The original relation event that is about to be redacted.
+     */
+
+
+    /**
+     * Get all events in this collection grouped by key and sorted by descending
+     * event count in each group.
+     *
+     * This is currently only supported for the annotation relation type.
+     *
+     * @return {Array}
+     * An array of [key, events] pairs sorted by descending event count.
+     * The events are stored in a Set (which preserves insertion order).
+     */
+    getSortedAnnotationsByKey() {
+        if (this.relationType !== "m.annotation") {
+            // Other relation types are not grouped currently.
+            return null;
+        }
+
+        return this._sortedAnnotationsByKey;
+    }
+
+    /**
+     * Get all events in this collection grouped by sender.
+     *
+     * This is currently only supported for the annotation relation type.
+     *
+     * @return {Object}
+     * An object with each relation sender as a key and the matching Set of
+     * events for that sender as a value.
+     */
+    getAnnotationsBySender() {
+        if (this.relationType !== "m.annotation") {
+            // Other relation types are not grouped currently.
+            return null;
+        }
+
+        return this._annotationsBySender;
+    }
+
+    /**
+     * Returns the most recent (and allowed) m.replace relation, if any.
+     *
+     * This is currently only supported for the m.replace relation type,
+     * once the target event is known, see `addEvent`.
+     *
+     * @return {MatrixEvent?}
+     */
+    getLastReplacement() {
+        if (this.relationType !== "m.replace") {
+            // Aggregating on last only makes sense for this relation type
+            return null;
+        }
+        if (!this._targetEvent) {
+            // Don't know which replacements to accept yet.
+            // This method shouldn't be called before the original
+            // event is known anyway.
+            return null;
+        }
+
+        // the all-knowning server tells us that the event at some point had
+        // this timestamp for its replacement, so any following replacement should definitely not be less
+        const replaceRelation = this._targetEvent.getServerAggregatedRelation("m.replace");
+        const minTs = replaceRelation && replaceRelation.origin_server_ts;
+
+        return this.getRelations().reduce((last, event) => {
+            if (event.getSender() !== this._targetEvent.getSender()) {
+                return last;
+            }
+            if (minTs && minTs > event.getTs()) {
+                return last;
+            }
+            if (last && last.getTs() > event.getTs()) {
+                return last;
+            }
+            return event;
+        }, null);
+    }
+
+    /*
+     * @param {MatrixEvent} targetEvent the event the relations are related to.
+     */
+    setTargetEvent(event) {
+        if (this._targetEvent) {
+            return;
+        }
+        this._targetEvent = event;
+        if (this.relationType === "m.replace") {
+            const replacement = this.getLastReplacement();
+            // this is the initial update, so only call it if we already have something
+            // to not emit Event.replaced needlessly
+            if (replacement) {
+                this._targetEvent.makeReplaced(replacement);
+            }
+        }
+    }
+}
+exports.default = Relations;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/room-member.js
@@ -12,77 +12,104 @@ distributed under the License is distrib
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * @module models/room-member
  */
-var EventEmitter = require("events").EventEmitter;
-var ContentRepo = require("../content-repo");
 
-var utils = require("../utils");
+const EventEmitter = require("events").EventEmitter;
+const ContentRepo = require("../content-repo");
+
+const utils = require("../utils");
 
 /**
  * Construct a new room member.
  *
  * @constructor
  * @alias module:models/room-member
  *
  * @param {string} roomId The room ID of the member.
  * @param {string} userId The user ID of the member.
  * @prop {string} roomId The room ID for this member.
  * @prop {string} userId The user ID of this member.
  * @prop {boolean} typing True if the room member is currently typing.
- * @prop {string} name The human-readable name for this room member.
+ * @prop {string} name The human-readable name for this room member. This will be
+ * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the
+ * same displayname.
+ * @prop {string} rawDisplayName The ambiguous displayname of this room member.
  * @prop {Number} powerLevel The power level for this room member.
  * @prop {Number} powerLevelNorm The normalised power level (0-100) for this
  * room member.
  * @prop {User} user The User object for this room member, if one exists.
  * @prop {string} membership The membership state for this room member e.g. 'join'.
  * @prop {Object} events The events describing this RoomMember.
  * @prop {MatrixEvent} events.member The m.room.member event for this RoomMember.
  */
 function RoomMember(roomId, userId) {
     this.roomId = roomId;
     this.userId = userId;
     this.typing = false;
     this.name = userId;
+    this.rawDisplayName = userId;
     this.powerLevel = 0;
     this.powerLevelNorm = 0;
     this.user = null;
     this.membership = null;
     this.events = {
         member: null
     };
+    this._isOutOfBand = false;
     this._updateModifiedTime();
 }
 utils.inherits(RoomMember, EventEmitter);
 
 /**
+ * Mark the member as coming from a channel that is not sync
+ */
+RoomMember.prototype.markOutOfBand = function () {
+    this._isOutOfBand = true;
+};
+
+/**
+ * @return {bool} does the member come from a channel that is not sync?
+ * This is used to store the member seperately
+ * from the sync state so it available across browser sessions.
+ */
+RoomMember.prototype.isOutOfBand = function () {
+    return this._isOutOfBand;
+};
+
+/**
  * Update this room member's membership event. May fire "RoomMember.name" if
  * this event updates this member's name.
  * @param {MatrixEvent} event The <code>m.room.member</code> event
  * @param {RoomState} roomState Optional. The room state to take into account
  * when calculating (e.g. for disambiguating users with the same name).
  * @fires module:client~MatrixClient#event:"RoomMember.name"
  * @fires module:client~MatrixClient#event:"RoomMember.membership"
  */
-RoomMember.prototype.setMembershipEvent = function(event, roomState) {
+RoomMember.prototype.setMembershipEvent = function (event, roomState) {
     if (event.getType() !== "m.room.member") {
         return;
     }
+
+    this._isOutOfBand = false;
+
     this.events.member = event;
 
-    var oldMembership = this.membership;
+    const oldMembership = this.membership;
     this.membership = event.getDirectionalContent().membership;
 
-    var oldName = this.name;
-    this.name = calculateDisplayName(this, event, roomState);
+    const oldName = this.name;
+    this.name = calculateDisplayName(this.userId, event.getDirectionalContent().displayname, roomState);
+
+    this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
     if (oldMembership !== this.membership) {
         this._updateModifiedTime();
         this.emit("RoomMember.membership", event, this, oldMembership);
     }
     if (oldName !== this.name) {
         this._updateModifiedTime();
         this.emit("RoomMember.name", event, this, oldName);
     }
@@ -90,146 +117,204 @@ RoomMember.prototype.setMembershipEvent 
 
 /**
  * Update this room member's power level event. May fire
  * "RoomMember.powerLevel" if this event updates this member's power levels.
  * @param {MatrixEvent} powerLevelEvent The <code>m.room.power_levels</code>
  * event
  * @fires module:client~MatrixClient#event:"RoomMember.powerLevel"
  */
-RoomMember.prototype.setPowerLevelEvent = function(powerLevelEvent) {
+RoomMember.prototype.setPowerLevelEvent = function (powerLevelEvent) {
     if (powerLevelEvent.getType() !== "m.room.power_levels") {
         return;
     }
-    var maxLevel = powerLevelEvent.getContent().users_default || 0;
-    utils.forEach(utils.values(powerLevelEvent.getContent().users), function(lvl) {
+
+    const evContent = powerLevelEvent.getDirectionalContent();
+
+    let maxLevel = evContent.users_default || 0;
+    utils.forEach(utils.values(evContent.users), function (lvl) {
         maxLevel = Math.max(maxLevel, lvl);
     });
-    var oldPowerLevel = this.powerLevel;
-    var oldPowerLevelNorm = this.powerLevelNorm;
+    const oldPowerLevel = this.powerLevel;
+    const oldPowerLevelNorm = this.powerLevelNorm;
 
-    if (powerLevelEvent.getContent().users[this.userId] !== undefined) {
-        this.powerLevel = powerLevelEvent.getContent().users[this.userId];
-    } else if (powerLevelEvent.getContent().users_default !== undefined) {
-        this.powerLevel = powerLevelEvent.getContent().users_default;
+    if (evContent.users && evContent.users[this.userId] !== undefined) {
+        this.powerLevel = evContent.users[this.userId];
+    } else if (evContent.users_default !== undefined) {
+        this.powerLevel = evContent.users_default;
     } else {
         this.powerLevel = 0;
     }
     this.powerLevelNorm = 0;
     if (maxLevel > 0) {
-        this.powerLevelNorm = (this.powerLevel * 100) / maxLevel;
+        this.powerLevelNorm = this.powerLevel * 100 / maxLevel;
     }
 
     // emit for changes in powerLevelNorm as well (since the app will need to
     // redraw everyone's level if the max has changed)
     if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) {
         this._updateModifiedTime();
         this.emit("RoomMember.powerLevel", powerLevelEvent, this);
     }
 };
 
 /**
  * Update this room member's typing event. May fire "RoomMember.typing" if
  * this event changes this member's typing state.
  * @param {MatrixEvent} event The typing event
  * @fires module:client~MatrixClient#event:"RoomMember.typing"
  */
-RoomMember.prototype.setTypingEvent = function(event) {
+RoomMember.prototype.setTypingEvent = function (event) {
     if (event.getType() !== "m.typing") {
         return;
     }
-    var oldTyping = this.typing;
+    const oldTyping = this.typing;
     this.typing = false;
-    var typingList = event.getContent().user_ids;
+    const typingList = event.getContent().user_ids;
     if (!utils.isArray(typingList)) {
         // malformed event :/ bail early. TODO: whine?
         return;
     }
     if (typingList.indexOf(this.userId) !== -1) {
         this.typing = true;
     }
     if (oldTyping !== this.typing) {
         this._updateModifiedTime();
         this.emit("RoomMember.typing", event, this);
     }
 };
 
 /**
  * Update the last modified time to the current time.
  */
-RoomMember.prototype._updateModifiedTime = function() {
+RoomMember.prototype._updateModifiedTime = function () {
     this._modified = Date.now();
 };
 
 /**
  * Get the timestamp when this RoomMember was last updated. This timestamp is
  * updated when properties on this RoomMember are updated.
  * It is updated <i>before</i> firing events.
  * @return {number} The timestamp
  */
-RoomMember.prototype.getLastModifiedTime = function() {
+RoomMember.prototype.getLastModifiedTime = function () {
     return this._modified;
 };
 
+RoomMember.prototype.isKicked = function () {
+    return this.membership === "leave" && this.events.member.getSender() !== this.events.member.getStateKey();
+};
+
+/**
+ * If this member was invited with the is_direct flag set, return
+ * the user that invited this member
+ * @return {string} user id of the inviter
+ */
+RoomMember.prototype.getDMInviter = function () {
+    // when not available because that room state hasn't been loaded in,
+    // we don't really know, but more likely to not be a direct chat
+    if (this.events.member) {
+        // TODO: persist the is_direct flag on the member as more member events
+        //       come in caused by displayName changes.
+
+        // the is_direct flag is set on the invite member event.
+        // This is copied on the prev_content section of the join member event
+        // when the invite is accepted.
+
+        const memberEvent = this.events.member;
+        let memberContent = memberEvent.getContent();
+        let inviteSender = memberEvent.getSender();
+
+        if (memberContent.membership === "join") {
+            memberContent = memberEvent.getPrevContent();
+            inviteSender = memberEvent.getUnsigned().prev_sender;
+        }
+
+        if (memberContent.membership === "invite" && memberContent.is_direct) {
+            return inviteSender;
+        }
+    }
+};
+
 /**
  * Get the avatar URL for a room member.
  * @param {string} baseUrl The base homeserver URL See
  * {@link module:client~MatrixClient#getHomeserverUrl}.
  * @param {Number} width The desired width of the thumbnail.
  * @param {Number} height The desired height of the thumbnail.
  * @param {string} resizeMethod The thumbnail resize method to use, either
  * "crop" or "scale".
  * @param {Boolean} allowDefault (optional) Passing false causes this method to
  * return null if the user has no avatar image. Otherwise, a default image URL
- * will be returned. Default: true.
+ * will be returned. Default: true. (Deprecated)
  * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be
  * returned even if it is a direct hyperlink rather than a matrix content URL.
  * If false, any non-matrix content URLs will be ignored. Setting this option to
  * true will expose URLs that, if fetched, will leak information about the user
  * to anyone who they share a room with.
  * @return {?string} the avatar URL or null.
  */
-RoomMember.prototype.getAvatarUrl =
-        function(baseUrl, width, height, resizeMethod, allowDefault, allowDirectLinks) {
-    if (allowDefault === undefined) { allowDefault = true; }
-    if (!this.events.member && !allowDefault) {
+RoomMember.prototype.getAvatarUrl = function (baseUrl, width, height, resizeMethod, allowDefault, allowDirectLinks) {
+    if (allowDefault === undefined) {
+        allowDefault = true;
+    }
+
+    const rawUrl = this.getMxcAvatarUrl();
+
+    if (!rawUrl && !allowDefault) {
         return null;
     }
-    var rawUrl = this.events.member ? this.events.member.getContent().avatar_url : null;
-    var httpUrl = ContentRepo.getHttpUriForMxc(
-        baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks
-    );
+    const httpUrl = ContentRepo.getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks);
     if (httpUrl) {
         return httpUrl;
+    } else if (allowDefault) {
+        return ContentRepo.getIdenticonUri(baseUrl, this.userId, width, height);
     }
-    else if (allowDefault) {
-        return ContentRepo.getIdenticonUri(
-            baseUrl, this.userId, width, height
-        );
+    return null;
+};
+/**
+ * get the mxc avatar url, either from a state event, or from a lazily loaded member
+ * @return {string} the mxc avatar url
+ */
+RoomMember.prototype.getMxcAvatarUrl = function () {
+    if (this.events.member) {
+        return this.events.member.getDirectionalContent().avatar_url;
+    } else if (this.user) {
+        return this.user.avatarUrl;
     }
     return null;
 };
 
-function calculateDisplayName(member, event, roomState) {
-    var displayName = event.getDirectionalContent().displayname;
-    var selfUserId = member.userId;
+function calculateDisplayName(selfUserId, displayName, roomState) {
+    if (!displayName || displayName === selfUserId) {
+        return selfUserId;
+    }
 
-    if (!displayName) {
+    // First check if the displayname is something we consider truthy
+    // after stripping it of zero width characters and padding spaces
+    if (!utils.removeHiddenChars(displayName)) {
         return selfUserId;
     }
 
     if (!roomState) {
         return displayName;
     }
 
-    var userIds = roomState.getUserIdsWithDisplayName(displayName);
-    var otherUsers = userIds.filter(function(u) {
-        return u !== selfUserId;
-    });
-    if (otherUsers.length > 0) {
+    // Next check if the name contains something that look like a mxid
+    // If it does, it may be someone trying to impersonate someone else
+    // Show full mxid in this case
+    // Also show mxid if there are other people with the same or similar
+    // displayname, after hidden character removal.
+    let disambiguate = /@.+:.+/.test(displayName);
+    if (!disambiguate) {
+        const userIds = roomState.getUserIdsWithDisplayName(displayName);
+        disambiguate = userIds.some(u => u !== selfUserId);
+    }
+
+    if (disambiguate) {
         return displayName + " (" + selfUserId + ")";
     }
     return displayName;
 }
 
 /**
  * The RoomMember class.
  */
@@ -277,9 +362,9 @@ module.exports = RoomMember;
  * @event module:client~MatrixClient#"RoomMember.powerLevel"
  * @param {MatrixEvent} event The matrix event which caused this event to fire.
  * @param {RoomMember} member The member whose RoomMember.powerLevel changed.
  * @example
  * matrixClient.on("RoomMember.powerLevel", function(event, member){
  *   var newPowerLevel = member.powerLevel;
  *   var newNormPowerLevel = member.powerLevelNorm;
  * });
- */
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/room-state.js
@@ -12,389 +12,755 @@ distributed under the License is distrib
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * @module models/room-state
  */
-var EventEmitter = require("events").EventEmitter;
+
+var _logger = require("../../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const EventEmitter = require("events").EventEmitter;
 
-var utils = require("../utils");
-var RoomMember = require("./room-member");
+const utils = require("../utils");
+const RoomMember = require("./room-member");
+
+
+// possible statuses for out-of-band member loading
+const OOB_STATUS_NOTSTARTED = 1;
+const OOB_STATUS_INPROGRESS = 2;
+const OOB_STATUS_FINISHED = 3;
 
 /**
  * Construct room state.
+ *
+ * Room State represents the state of the room at a given point.
+ * It can be mutated by adding state events to it.
+ * There are two types of room member associated with a state event:
+ * normal member objects (accessed via getMember/getMembers) which mutate
+ * with the state to represent the current state of that room/user, eg.
+ * the object returned by getMember('@bob:example.com') will mutate to
+ * get a different display name if Bob later changes his display name
+ * in the room.
+ * There are also 'sentinel' members (accessed via getSentinelMember).
+ * These also represent the state of room members at the point in time
+ * represented by the RoomState object, but unlike objects from getMember,
+ * sentinel objects will always represent the room state as at the time
+ * getSentinelMember was called, so if Bob subsequently changes his display
+ * name, a room member object previously acquired with getSentinelMember
+ * will still have his old display name. Calling getSentinelMember again
+ * after the display name change will return a new RoomMember object
+ * with Bob's new display name.
+ *
  * @constructor
  * @param {?string} roomId Optional. The ID of the room which has this state.
  * If none is specified it just tracks paginationTokens, useful for notifTimelineSet
+ * @param {?object} oobMemberFlags Optional. The state of loading out of bound members.
+ * As the timeline might get reset while they are loading, this state needs to be inherited
+ * and shared when the room state is cloned for the new timeline.
+ * This should only be passed from clone.
  * @prop {Object.<string, RoomMember>} members The room member dictionary, keyed
  * on the user's ID.
  * @prop {Object.<string, Object.<string, MatrixEvent>>} events The state
  * events dictionary, keyed on the event type and then the state_key value.
  * @prop {string} paginationToken The pagination token for this state.
  */
-function RoomState(roomId) {
+function RoomState(roomId, oobMemberFlags = undefined) {
     this.roomId = roomId;
     this.members = {
         // userId: RoomMember
     };
     this.events = {
         // eventType: { stateKey: MatrixEvent }
     };
     this.paginationToken = null;
 
     this._sentinels = {
         // userId: RoomMember
     };
     this._updateModifiedTime();
+
+    // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys)
     this._displayNameToUserIds = {};
     this._userIdsToDisplayNames = {};
     this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite
+    this._joinedMemberCount = null; // cache of the number of joined members
+    // joined members count from summary api
+    // once set, we know the server supports the summary api
+    // and we should only trust that
+    // we could also only trust that before OOB members
+    // are loaded but doesn't seem worth the hassle atm
+    this._summaryJoinedMemberCount = null;
+    // same for invited member count
+    this._invitedMemberCount = null;
+    this._summaryInvitedMemberCount = null;
+
+    if (!oobMemberFlags) {
+        oobMemberFlags = {
+            status: OOB_STATUS_NOTSTARTED
+        };
+    }
+    this._oobMemberFlags = oobMemberFlags;
 }
 utils.inherits(RoomState, EventEmitter);
 
 /**
+ * Returns the number of joined members in this room
+ * This method caches the result.
+ * @return {integer} The number of members in this room whose membership is 'join'
+ */
+RoomState.prototype.getJoinedMemberCount = function () {
+    if (this._summaryJoinedMemberCount !== null) {
+        return this._summaryJoinedMemberCount;
+    }
+    if (this._joinedMemberCount === null) {
+        this._joinedMemberCount = this.getMembers().reduce((count, m) => {
+            return m.membership === 'join' ? count + 1 : count;
+        }, 0);
+    }
+    return this._joinedMemberCount;
+};
+
+/**
+ * Set the joined member count explicitly (like from summary part of the sync response)
+ * @param {number} count the amount of joined members
+ */
+RoomState.prototype.setJoinedMemberCount = function (count) {
+    this._summaryJoinedMemberCount = count;
+};
+/**
+ * Returns the number of invited members in this room
+ * @return {integer} The number of members in this room whose membership is 'invite'
+ */
+RoomState.prototype.getInvitedMemberCount = function () {
+    if (this._summaryInvitedMemberCount !== null) {
+        return this._summaryInvitedMemberCount;
+    }
+    if (this._invitedMemberCount === null) {
+        this._invitedMemberCount = this.getMembers().reduce((count, m) => {
+            return m.membership === 'invite' ? count + 1 : count;
+        }, 0);
+    }
+    return this._invitedMemberCount;
+};
+
+/**
+ * Set the amount of invited members in this room
+ * @param {number} count the amount of invited members
+ */
+RoomState.prototype.setInvitedMemberCount = function (count) {
+    this._summaryInvitedMemberCount = count;
+};
+
+/**
  * Get all RoomMembers in this room.
  * @return {Array<RoomMember>} A list of RoomMembers.
  */
-RoomState.prototype.getMembers = function() {
+RoomState.prototype.getMembers = function () {
     return utils.values(this.members);
 };
 
 /**
+ * Get all RoomMembers in this room, excluding the user IDs provided.
+ * @param {Array<string>} excludedIds The user IDs to exclude.
+ * @return {Array<RoomMember>} A list of RoomMembers.
+ */
+RoomState.prototype.getMembersExcept = function (excludedIds) {
+    return utils.values(this.members).filter(m => !excludedIds.includes(m.userId));
+};
+
+/**
  * Get a room member by their user ID.
  * @param {string} userId The room member's user ID.
  * @return {RoomMember} The member or null if they do not exist.
  */
-RoomState.prototype.getMember = function(userId) {
+RoomState.prototype.getMember = function (userId) {
     return this.members[userId] || null;
 };
 
 /**
  * Get a room member whose properties will not change with this room state. You
  * typically want this if you want to attach a RoomMember to a MatrixEvent which
  * may no longer be represented correctly by Room.currentState or Room.oldState.
  * The term 'sentinel' refers to the fact that this RoomMember is an unchanging
  * guardian for state at this particular point in time.
  * @param {string} userId The room member's user ID.
  * @return {RoomMember} The member or null if they do not exist.
  */
-RoomState.prototype.getSentinelMember = function(userId) {
-    return this._sentinels[userId] || null;
+RoomState.prototype.getSentinelMember = function (userId) {
+    if (!userId) return null;
+    let sentinel = this._sentinels[userId];
+
+    if (sentinel === undefined) {
+        sentinel = new RoomMember(this.roomId, userId);
+        const member = this.members[userId];
+        if (member) {
+            sentinel.setMembershipEvent(member.events.member, this);
+        }
+        this._sentinels[userId] = sentinel;
+    }
+    return sentinel;
 };
 
 /**
  * Get state events from the state of the room.
  * @param {string} eventType The event type of the state event.
  * @param {string} stateKey Optional. The state_key of the state event. If
  * this is <code>undefined</code> then all matching state events will be
  * returned.
  * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was
  * <code>undefined</code>, else a single event (or null if no match found).
  */
-RoomState.prototype.getStateEvents = function(eventType, stateKey) {
+RoomState.prototype.getStateEvents = function (eventType, stateKey) {
     if (!this.events[eventType]) {
         // no match
         return stateKey === undefined ? [] : null;
     }
-    if (stateKey === undefined) { // return all values
+    if (stateKey === undefined) {
+        // return all values
         return utils.values(this.events[eventType]);
     }
-    var event = this.events[eventType][stateKey];
+    const event = this.events[eventType][stateKey];
     return event ? event : null;
 };
 
 /**
+ * Creates a copy of this room state so that mutations to either won't affect the other.
+ * @return {RoomState} the copy of the room state
+ */
+RoomState.prototype.clone = function () {
+    const copy = new RoomState(this.roomId, this._oobMemberFlags);
+
+    // Ugly hack: because setStateEvents will mark
+    // members as susperseding future out of bound members
+    // if loading is in progress (through _oobMemberFlags)
+    // since these are not new members, we're merely copying them
+    // set the status to not started
+    // after copying, we set back the status
+    const status = this._oobMemberFlags.status;
+    this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
+
+    Object.values(this.events).forEach(eventsByStateKey => {
+        const eventsForType = Object.values(eventsByStateKey);
+        copy.setStateEvents(eventsForType);
+    });
+
+    // Ugly hack: see above
+    this._oobMemberFlags.status = status;
+
+    if (this._summaryInvitedMemberCount !== null) {
+        copy.setInvitedMemberCount(this.getInvitedMemberCount());
+    }
+    if (this._summaryJoinedMemberCount !== null) {
+        copy.setJoinedMemberCount(this.getJoinedMemberCount());
+    }
+
+    // copy out of band flags if needed
+    if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) {
+        // copy markOutOfBand flags
+        this.getMembers().forEach(member => {
+            if (member.isOutOfBand()) {
+                const copyMember = copy.getMember(member.userId);
+                copyMember.markOutOfBand();
+            }
+        });
+    }
+
+    return copy;
+};
+
+/**
+ * Add previously unknown state events.
+ * When lazy loading members while back-paginating,
+ * the relevant room state for the timeline chunk at the end
+ * of the chunk can be set with this method.
+ * @param {MatrixEvent[]} events state events to prepend
+ */
+RoomState.prototype.setUnknownStateEvents = function (events) {
+    const unknownStateEvents = events.filter(event => {
+        return this.events[event.getType()] === undefined || this.events[event.getType()][event.getStateKey()] === undefined;
+    });
+
+    this.setStateEvents(unknownStateEvents);
+};
+
+/**
  * Add an array of one or more state MatrixEvents, overwriting
  * any existing state with the same {type, stateKey} tuple. Will fire
  * "RoomState.events" for every event added. May fire "RoomState.members"
  * if there are <code>m.room.member</code> events.
  * @param {MatrixEvent[]} stateEvents a list of state events for this room.
  * @fires module:client~MatrixClient#event:"RoomState.members"
  * @fires module:client~MatrixClient#event:"RoomState.newMember"
  * @fires module:client~MatrixClient#event:"RoomState.events"
  */
-RoomState.prototype.setStateEvents = function(stateEvents) {
-    var self = this;
+RoomState.prototype.setStateEvents = function (stateEvents) {
+    const self = this;
     this._updateModifiedTime();
 
     // update the core event dict
-    utils.forEach(stateEvents, function(event) {
-        if (event.getRoomId() !== self.roomId) { return; }
-        if (!event.isState()) { return; }
+    utils.forEach(stateEvents, function (event) {
+        if (event.getRoomId() !== self.roomId) {
+            return;
+        }
+        if (!event.isState()) {
+            return;
+        }
 
-        if (self.events[event.getType()] === undefined) {
-            self.events[event.getType()] = {};
-        }
-        self.events[event.getType()][event.getStateKey()] = event;
+        self._setStateEvent(event);
         if (event.getType() === "m.room.member") {
-            _updateDisplayNameCache(
-                self, event.getStateKey(), event.getContent().displayname
-            );
+            _updateDisplayNameCache(self, event.getStateKey(), event.getContent().displayname);
             _updateThirdPartyTokenCache(self, event);
         }
         self.emit("RoomState.events", event, self);
     });
 
     // update higher level data structures. This needs to be done AFTER the
     // core event dict as these structures may depend on other state events in
     // the given array (e.g. disambiguating display names in one go to do both
     // clashing names rather than progressively which only catches 1 of them).
-    utils.forEach(stateEvents, function(event) {
-        if (event.getRoomId() !== self.roomId) { return; }
-        if (!event.isState()) { return; }
+    utils.forEach(stateEvents, function (event) {
+        if (event.getRoomId() !== self.roomId) {
+            return;
+        }
+        if (!event.isState()) {
+            return;
+        }
 
         if (event.getType() === "m.room.member") {
-            var userId = event.getStateKey();
+            const userId = event.getStateKey();
 
             // leave events apparently elide the displayname or avatar_url,
             // so let's fake one up so that we don't leak user ids
             // into the timeline
-            if (event.getContent().membership === "leave" ||
-                event.getContent().membership === "ban")
-            {
-                event.getContent().avatar_url =
-                    event.getContent().avatar_url ||
-                    event.getPrevContent().avatar_url;
-                event.getContent().displayname =
-                    event.getContent().displayname ||
-                    event.getPrevContent().displayname;
+            if (event.getContent().membership === "leave" || event.getContent().membership === "ban") {
+                event.getContent().avatar_url = event.getContent().avatar_url || event.getPrevContent().avatar_url;
+                event.getContent().displayname = event.getContent().displayname || event.getPrevContent().displayname;
             }
 
-            var member = self.members[userId];
-            if (!member) {
-                member = new RoomMember(event.getRoomId(), userId);
-                self.emit("RoomState.newMember", event, self, member);
-            }
-            // Add a new sentinel for this change. We apply the same
-            // operations to both sentinel and member rather than deep copying
-            // so we don't make assumptions about the properties of RoomMember
-            // (e.g. and manage to break it because deep copying doesn't do
-            // everything).
-            var sentinel = new RoomMember(event.getRoomId(), userId);
-            utils.forEach([member, sentinel], function(roomMember) {
-                roomMember.setMembershipEvent(event, self);
-                // this member may have a power level already, so set it.
-                var pwrLvlEvent = self.getStateEvents("m.room.power_levels", "");
-                if (pwrLvlEvent) {
-                    roomMember.setPowerLevelEvent(pwrLvlEvent);
-                }
-            });
+            const member = self._getOrCreateMember(userId, event);
+            member.setMembershipEvent(event, self);
 
-            self._sentinels[userId] = sentinel;
-            self.members[userId] = member;
+            self._updateMember(member);
             self.emit("RoomState.members", event, self, member);
-        }
-        else if (event.getType() === "m.room.power_levels") {
-            var members = utils.values(self.members);
-            utils.forEach(members, function(member) {
+        } else if (event.getType() === "m.room.power_levels") {
+            const members = utils.values(self.members);
+            utils.forEach(members, function (member) {
                 member.setPowerLevelEvent(event);
                 self.emit("RoomState.members", event, self, member);
             });
+
+            // assume all our sentinels are now out-of-date
+            self._sentinels = {};
         }
     });
 };
 
 /**
+ * Looks up a member by the given userId, and if it doesn't exist,
+ * create it and emit the `RoomState.newMember` event.
+ * This method makes sure the member is added to the members dictionary
+ * before emitting, as this is done from setStateEvents and _setOutOfBandMember.
+ * @param {string} userId the id of the user to look up
+ * @param {MatrixEvent} event the membership event for the (new) member. Used to emit.
+ * @fires module:client~MatrixClient#event:"RoomState.newMember"
+ * @returns {RoomMember} the member, existing or newly created.
+ */
+RoomState.prototype._getOrCreateMember = function (userId, event) {
+    let member = this.members[userId];
+    if (!member) {
+        member = new RoomMember(this.roomId, userId);
+        // add member to members before emitting any events,
+        // as event handlers often lookup the member
+        this.members[userId] = member;
+        this.emit("RoomState.newMember", event, this, member);
+    }
+    return member;
+};
+
+RoomState.prototype._setStateEvent = function (event) {
+    if (this.events[event.getType()] === undefined) {
+        this.events[event.getType()] = {};
+    }
+    this.events[event.getType()][event.getStateKey()] = event;
+};
+
+RoomState.prototype._updateMember = function (member) {
+    // this member may have a power level already, so set it.
+    const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
+    if (pwrLvlEvent) {
+        member.setPowerLevelEvent(pwrLvlEvent);
+    }
+
+    // blow away the sentinel which is now outdated
+    delete this._sentinels[member.userId];
+
+    this.members[member.userId] = member;
+    this._joinedMemberCount = null;
+    this._invitedMemberCount = null;
+};
+
+/**
+ * Get the out-of-band members loading state, whether loading is needed or not.
+ * Note that loading might be in progress and hence isn't needed.
+ * @return {bool} whether or not the members of this room need to be loaded
+ */
+RoomState.prototype.needsOutOfBandMembers = function () {
+    return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED;
+};
+
+/**
+ * Mark this room state as waiting for out-of-band members,
+ * ensuring it doesn't ask for them to be requested again
+ * through needsOutOfBandMembers
+ */
+RoomState.prototype.markOutOfBandMembersStarted = function () {
+    if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) {
+        return;
+    }
+    this._oobMemberFlags.status = OOB_STATUS_INPROGRESS;
+};
+
+/**
+ * Mark this room state as having failed to fetch out-of-band members
+ */
+RoomState.prototype.markOutOfBandMembersFailed = function () {
+    if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
+        return;
+    }
+    this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
+};
+
+/**
+ * Clears the loaded out-of-band members
+ */
+RoomState.prototype.clearOutOfBandMembers = function () {
+    let count = 0;
+    Object.keys(this.members).forEach(userId => {
+        const member = this.members[userId];
+        if (member.isOutOfBand()) {
+            ++count;
+            delete this.members[userId];
+        }
+    });
+    _logger2.default.log(`LL: RoomState removed ${count} members...`);
+    this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
+};
+
+/**
+ * Sets the loaded out-of-band members.
+ * @param {MatrixEvent[]} stateEvents array of membership state events
+ */
+RoomState.prototype.setOutOfBandMembers = function (stateEvents) {
+    _logger2.default.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
+    if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
+        return;
+    }
+    _logger2.default.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`);
+    this._oobMemberFlags.status = OOB_STATUS_FINISHED;
+    stateEvents.forEach(e => this._setOutOfBandMember(e));
+};
+
+/**
+ * Sets a single out of band member, used by both setOutOfBandMembers and clone
+ * @param {MatrixEvent} stateEvent membership state event
+ */
+RoomState.prototype._setOutOfBandMember = function (stateEvent) {
+    if (stateEvent.getType() !== 'm.room.member') {
+        return;
+    }
+    const userId = stateEvent.getStateKey();
+    const existingMember = this.getMember(userId);
+    // never replace members received as part of the sync
+    if (existingMember && !existingMember.isOutOfBand()) {
+        return;
+    }
+
+    const member = this._getOrCreateMember(userId, stateEvent);
+    member.setMembershipEvent(stateEvent, this);
+    // needed to know which members need to be stored seperately
+    // as they are not part of the sync accumulator
+    // this is cleared by setMembershipEvent so when it's updated through /sync
+    member.markOutOfBand();
+
+    _updateDisplayNameCache(this, member.userId, member.name);
+
+    this._setStateEvent(stateEvent);
+    this._updateMember(member);
+    this.emit("RoomState.members", stateEvent, this, member);
+};
+
+/**
  * Set the current typing event for this room.
  * @param {MatrixEvent} event The typing event
  */
-RoomState.prototype.setTypingEvent = function(event) {
-    utils.forEach(utils.values(this.members), function(member) {
+RoomState.prototype.setTypingEvent = function (event) {
+    utils.forEach(utils.values(this.members), function (member) {
         member.setTypingEvent(event);
     });
 };
 
 /**
  * Get the m.room.member event which has the given third party invite token.
  *
  * @param {string} token The token
  * @return {?MatrixEvent} The m.room.member event or null
  */
-RoomState.prototype.getInviteForThreePidToken = function(token) {
+RoomState.prototype.getInviteForThreePidToken = function (token) {
     return this._tokenToInvite[token] || null;
 };
 
 /**
  * Update the last modified time to the current time.
  */
-RoomState.prototype._updateModifiedTime = function() {
+RoomState.prototype._updateModifiedTime = function () {
     this._modified = Date.now();
 };
 
 /**
  * Get the timestamp when this room state was last updated. This timestamp is
  * updated when this object has received new state events.
  * @return {number} The timestamp
  */
-RoomState.prototype.getLastModifiedTime = function() {
+RoomState.prototype.getLastModifiedTime = function () {
     return this._modified;
 };
 
 /**
- * Get user IDs with the specified display name.
+ * Get user IDs with the specified or similar display names.
  * @param {string} displayName The display name to get user IDs from.
  * @return {string[]} An array of user IDs or an empty array.
  */
-RoomState.prototype.getUserIdsWithDisplayName = function(displayName) {
-    return this._displayNameToUserIds[displayName] || [];
+RoomState.prototype.getUserIdsWithDisplayName = function (displayName) {
+    return this._displayNameToUserIds[utils.removeHiddenChars(displayName)] || [];
+};
+
+/**
+ * Returns true if userId is in room, event is not redacted and either sender of
+ * mxEvent or has power level sufficient to redact events other than their own.
+ * @param {MatrixEvent} mxEvent The event to test permission for
+ * @param {string} userId The user ID of the user to test permission for
+ * @return {boolean} true if the given used ID can redact given event
+ */
+RoomState.prototype.maySendRedactionForEvent = function (mxEvent, userId) {
+    const member = this.getMember(userId);
+    if (!member || member.membership === 'leave') return false;
+
+    if (mxEvent.status || mxEvent.isRedacted()) return false;
+
+    // The user may have been the sender, but they can't redact their own message
+    // if redactions are blocked.
+    const canRedact = this.maySendEvent("m.room.redaction", userId);
+    if (mxEvent.getSender() === userId) return canRedact;
+
+    return this._hasSufficientPowerLevelFor('redact', member.powerLevel);
+};
+
+/**
+ * Returns true if the given power level is sufficient for action
+ * @param {string} action The type of power level to check
+ * @param {number} powerLevel The power level of the member
+ * @return {boolean} true if the given power level is sufficient
+ */
+RoomState.prototype._hasSufficientPowerLevelFor = function (action, powerLevel) {
+    const powerLevelsEvent = this.getStateEvents('m.room.power_levels', '');
+
+    let powerLevels = {};
+    if (powerLevelsEvent) {
+        powerLevels = powerLevelsEvent.getContent();
+    }
+
+    let requiredLevel = 50;
+    if (utils.isNumber(powerLevels[action])) {
+        requiredLevel = powerLevels[action];
+    }
+
+    return powerLevel >= requiredLevel;
 };
 
 /**
  * Short-form for maySendEvent('m.room.message', userId)
  * @param {string} userId The user ID of the user to test permission for
  * @return {boolean} true if the given user ID should be permitted to send
  *                   message events into the given room.
  */
-RoomState.prototype.maySendMessage = function(userId) {
+RoomState.prototype.maySendMessage = function (userId) {
     return this._maySendEventOfType('m.room.message', userId, false);
 };
 
 /**
  * Returns true if the given user ID has permission to send a normal
  * event of type `eventType` into this room.
- * @param {string} type The type of event to test
+ * @param {string} eventType The type of event to test
  * @param {string} userId The user ID of the user to test permission for
  * @return {boolean} true if the given user ID should be permitted to send
  *                        the given type of event into this room,
  *                        according to the room's state.
  */
-RoomState.prototype.maySendEvent = function(eventType, userId) {
+RoomState.prototype.maySendEvent = function (eventType, userId) {
     return this._maySendEventOfType(eventType, userId, false);
 };
 
-
 /**
  * Returns true if the given MatrixClient has permission to send a state
  * event of type `stateEventType` into this room.
- * @param {string} type The type of state events to test
- * @param {MatrixClient}  The client to test permission for
+ * @param {string} stateEventType The type of state events to test
+ * @param {MatrixClient} cli The client to test permission for
  * @return {boolean} true if the given client should be permitted to send
  *                        the given type of state event into this room,
  *                        according to the room's state.
  */
-RoomState.prototype.mayClientSendStateEvent = function(stateEventType, cli) {
+RoomState.prototype.mayClientSendStateEvent = function (stateEventType, cli) {
     if (cli.isGuest()) {
         return false;
     }
     return this.maySendStateEvent(stateEventType, cli.credentials.userId);
 };
 
 /**
  * Returns true if the given user ID has permission to send a state
  * event of type `stateEventType` into this room.
- * @param {string} type The type of state events to test
+ * @param {string} stateEventType The type of state events to test
  * @param {string} userId The user ID of the user to test permission for
  * @return {boolean} true if the given user ID should be permitted to send
  *                        the given type of state event into this room,
  *                        according to the room's state.
  */
-RoomState.prototype.maySendStateEvent = function(stateEventType, userId) {
+RoomState.prototype.maySendStateEvent = function (stateEventType, userId) {
     return this._maySendEventOfType(stateEventType, userId, true);
 };
 
 /**
  * Returns true if the given user ID has permission to send a normal or state
  * event of type `eventType` into this room.
- * @param {string} type The type of event to test
+ * @param {string} eventType The type of event to test
  * @param {string} userId The user ID of the user to test permission for
  * @param {boolean} state If true, tests if the user may send a state
                           event of this type. Otherwise tests whether
                           they may send a regular event.
  * @return {boolean} true if the given user ID should be permitted to send
  *                        the given type of event into this room,
  *                        according to the room's state.
  */
-RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
-    var member = this.getMember(userId);
-    if (!member || member.membership == 'leave') { return false; }
-
-    var power_levels_event = this.getStateEvents('m.room.power_levels', '');
+RoomState.prototype._maySendEventOfType = function (eventType, userId, state) {
+    const power_levels_event = this.getStateEvents('m.room.power_levels', '');
 
-    var power_levels;
-    var events_levels = {};
+    let power_levels;
+    let events_levels = {};
 
-    var default_user_level = 0;
-    var user_levels = [];
-
-    var state_default = 0;
-    var events_default = 0;
+    let state_default = 0;
+    let events_default = 0;
+    let powerLevel = 0;
     if (power_levels_event) {
         power_levels = power_levels_event.getContent();
         events_levels = power_levels.events || {};
 
-        default_user_level = parseInt(power_levels.users_default || 0);
-        user_levels = power_levels.users || {};
-
-        if (power_levels.state_default !== undefined) {
+        if (Number.isFinite(power_levels.state_default)) {
             state_default = power_levels.state_default;
         } else {
             state_default = 50;
         }
-        if (power_levels.events_default !== undefined) {
+
+        const userPowerLevel = power_levels.users && power_levels.users[userId];
+        if (Number.isFinite(userPowerLevel)) {
+            powerLevel = userPowerLevel;
+        } else if (Number.isFinite(power_levels.users_default)) {
+            powerLevel = power_levels.users_default;
+        }
+
+        if (Number.isFinite(power_levels.events_default)) {
             events_default = power_levels.events_default;
         }
     }
 
-    var required_level = state ? state_default : events_default;
-    if (events_levels[eventType] !== undefined) {
+    let required_level = state ? state_default : events_default;
+    if (Number.isFinite(events_levels[eventType])) {
         required_level = events_levels[eventType];
     }
-    return member.powerLevel >= required_level;
+    return powerLevel >= required_level;
+};
+
+/**
+ * Returns true if the given user ID has permission to trigger notification
+ * of type `notifLevelKey`
+ * @param {string} notifLevelKey The level of notification to test (eg. 'room')
+ * @param {string} userId The user ID of the user to test permission for
+ * @return {boolean} true if the given user ID has permission to trigger a
+ *                        notification of this type.
+ */
+RoomState.prototype.mayTriggerNotifOfType = function (notifLevelKey, userId) {
+    const member = this.getMember(userId);
+    if (!member) {
+        return false;
+    }
+
+    const powerLevelsEvent = this.getStateEvents('m.room.power_levels', '');
+
+    let notifLevel = 50;
+    if (powerLevelsEvent && powerLevelsEvent.getContent() && powerLevelsEvent.getContent().notifications && utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])) {
+        notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
+    }
+
+    return member.powerLevel >= notifLevel;
 };
 
 /**
  * The RoomState class.
  */
 module.exports = RoomState;
 
-
 function _updateThirdPartyTokenCache(roomState, memberEvent) {
     if (!memberEvent.getContent().third_party_invite) {
         return;
     }
-    var token = (memberEvent.getContent().third_party_invite.signed || {}).token;
+    const token = (memberEvent.getContent().third_party_invite.signed || {}).token;
     if (!token) {
         return;
     }
-    var threePidInvite = roomState.getStateEvents(
-        "m.room.third_party_invite", token
-    );
+    const threePidInvite = roomState.getStateEvents("m.room.third_party_invite", token);
     if (!threePidInvite) {
         return;
     }
     roomState._tokenToInvite[token] = memberEvent;
 }
 
 function _updateDisplayNameCache(roomState, userId, displayName) {
-    var oldName = roomState._userIdsToDisplayNames[userId];
+    const oldName = roomState._userIdsToDisplayNames[userId];
     delete roomState._userIdsToDisplayNames[userId];
     if (oldName) {
         // Remove the old name from the cache.
         // We clobber the user_id > name lookup but the name -> [user_id] lookup
         // means we need to remove that user ID from that array rather than nuking
         // the lot.
-        var existingUserIds = roomState._displayNameToUserIds[oldName] || [];
-        for (var i = 0; i < existingUserIds.length; i++) {
-            if (existingUserIds[i] === userId) {
-                // remove this user ID from this array
-                existingUserIds.splice(i, 1);
-                i--;
-            }
+        const strippedOldName = utils.removeHiddenChars(oldName);
+
+        const existingUserIds = roomState._displayNameToUserIds[strippedOldName];
+        if (existingUserIds) {
+            // remove this user ID from this array
+            const filteredUserIDs = existingUserIds.filter(id => id !== userId);
+            roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs;
         }
-        roomState._displayNameToUserIds[oldName] = existingUserIds;
     }
 
     roomState._userIdsToDisplayNames[userId] = displayName;
-    if (!roomState._displayNameToUserIds[displayName]) {
-        roomState._displayNameToUserIds[displayName] = [];
+
+    const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
+    // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
+    if (strippedDisplayname) {
+        if (!roomState._displayNameToUserIds[strippedDisplayname]) {
+            roomState._displayNameToUserIds[strippedDisplayname] = [];
+        }
+        roomState._displayNameToUserIds[strippedDisplayname].push(userId);
     }
-    roomState._displayNameToUserIds[displayName].push(userId);
 }
 
 /**
  * Fires whenever the event dictionary in room state is updated.
  * @event module:client~MatrixClient#"RoomState.events"
  * @param {MatrixEvent} event The matrix event which caused this event to fire.
  * @param {RoomState} state The room state whose RoomState.events dictionary
  * was updated.
@@ -412,21 +778,22 @@ function _updateDisplayNameCache(roomSta
  * was updated.
  * @param {RoomMember} member The room member that was updated.
  * @example
  * matrixClient.on("RoomState.members", function(event, state, member){
  *   var newMembershipState = member.membership;
  * });
  */
 
- /**
- * Fires whenever a member is added to the members dictionary. The RoomMember
- * will not be fully populated yet (e.g. no membership state).
- * @event module:client~MatrixClient#"RoomState.newMember"
- * @param {MatrixEvent} event The matrix event which caused this event to fire.
- * @param {RoomState} state The room state whose RoomState.members dictionary
- * was updated with a new entry.
- * @param {RoomMember} member The room member that was added.
- * @example
- * matrixClient.on("RoomState.newMember", function(event, state, member){
- *   // add event listeners on 'member'
- * });
- */
+/**
+* Fires whenever a member is added to the members dictionary. The RoomMember
+* will not be fully populated yet (e.g. no membership state) but will already
+* be available in the members dictionary.
+* @event module:client~MatrixClient#"RoomState.newMember"
+* @param {MatrixEvent} event The matrix event which caused this event to fire.
+* @param {RoomState} state The room state whose RoomState.members dictionary
+* was updated with a new entry.
+* @param {RoomMember} member The room member that was added.
+* @example
+* matrixClient.on("RoomState.newMember", function(event, state, member){
+*   // add event listeners on 'member'
+* });
+*/
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/room-summary.js
@@ -26,17 +26,18 @@ limitations under the License.
  * @param {Object} info Optional. The summary info. Additional keys are supported.
  * @param {string} info.title The title of the room (e.g. <code>m.room.name</code>)
  * @param {string} info.desc The description of the room (e.g.
  * <code>m.room.topic</code>)
  * @param {Number} info.numMembers The number of joined users.
  * @param {string[]} info.aliases The list of aliases for this room.
  * @param {Number} info.timestamp The timestamp for this room.
  */
+
 function RoomSummary(roomId, info) {
-    this.roomId = roomId;
-    this.info = info;
+  this.roomId = roomId;
+  this.info = info;
 }
 
 /**
  * The RoomSummary class.
  */
-module.exports = RoomSummary;
+module.exports = RoomSummary;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/room.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/room.js
@@ -1,10 +1,12 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018, 2019 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -12,45 +14,64 @@ distributed under the License is distrib
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * @module models/room
  */
-var EventEmitter = require("events").EventEmitter;
+
+var _logger = require("../../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _ReEmitter = require("../ReEmitter");
+
+var _ReEmitter2 = _interopRequireDefault(_ReEmitter);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const EventEmitter = require("events").EventEmitter;
 
-var EventStatus = require("./event").EventStatus;
-var RoomSummary = require("./room-summary");
-var MatrixEvent = require("./event").MatrixEvent;
-var utils = require("../utils");
-var ContentRepo = require("../content-repo");
-var EventTimeline = require("./event-timeline");
-var EventTimelineSet = require("./event-timeline-set");
+const EventStatus = require("./event").EventStatus;
+const RoomSummary = require("./room-summary");
+const RoomMember = require("./room-member");
+const MatrixEvent = require("./event").MatrixEvent;
+const utils = require("../utils");
+const ContentRepo = require("../content-repo");
+const EventTimeline = require("./event-timeline");
+const EventTimelineSet = require("./event-timeline-set");
 
+// These constants are used as sane defaults when the homeserver doesn't support
+// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
+// the same as the common default room version whereas SAFE_ROOM_VERSIONS are the
+// room versions which are considered okay for people to run without being asked
+// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
+// return an m.room_versions capability.
+const KNOWN_SAFE_ROOM_VERSION = '4';
+const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4'];
 
 function synthesizeReceipt(userId, event, receiptType) {
     // console.log("synthesizing receipt for "+event.getId());
     // This is really ugly because JS has no way to express an object literal
     // where the name of a key comes from an expression
-    var fakeReceipt = {
+    const fakeReceipt = {
         content: {},
         type: "m.receipt",
         room_id: event.getRoomId()
     };
     fakeReceipt.content[event.getId()] = {};
     fakeReceipt.content[event.getId()][receiptType] = {};
     fakeReceipt.content[event.getId()][receiptType][userId] = {
         ts: event.getTs()
     };
     return new MatrixEvent(fakeReceipt);
 }
 
-
 /**
  * Construct a new Room.
  *
  * <p>For a room, we store an ordered sequence of timelines, which may or may not
  * be continuous. Each timeline lists a series of events, as well as tracking
  * the room state at the start and the end of the timeline. It also tracks
  * forward and backward pagination tokens, as well as containing links to the
  * next timeline in the sequence.
@@ -62,61 +83,66 @@ function synthesizeReceipt(userId, event
  * so if the server gives us a timeline gap in /sync.
  *
  * <p>In order that we can find events from their ids later, we also maintain a
  * map from event_id to timeline and index.
  *
  * @constructor
  * @alias module:models/room
  * @param {string} roomId Required. The ID of this room.
+ * @param {MatrixClient} client Required. The client, used to lazy load members.
+ * @param {string} myUserId Required. The ID of the syncing user.
  * @param {Object=} opts Configuration options
  * @param {*} opts.storageToken Optional. The token which a data store can use
  * to remember the state of the room. What this means is dependent on the store
  * implementation.
  *
  * @param {String=} opts.pendingEventOrdering Controls where pending messages
  * appear in a room's timeline. If "<b>chronological</b>", messages will appear
  * in the timeline when the call to <code>sendEvent</code> was made. If
  * "<b>detached</b>", pending messages will appear in a separate list,
  * accessbile via {@link module:models/room#getPendingEvents}. Default:
  * "chronological".
- *
  * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved
  * timeline support.
+ * @param {boolean} [opts.unstableClientRelationAggregation = false]
+ * Optional. Set to true to enable client-side aggregation of event relations
+ * via `EventTimelineSet#getRelationsForEvent`.
+ * This feature is currently unstable and the API may change without notice.
  *
  * @prop {string} roomId The ID of this room.
  * @prop {string} name The human-readable display name for this room.
  * @prop {Array<MatrixEvent>} timeline The live event timeline for this room,
  * with the oldest event at index 0. Present for backwards compatibility -
  * prefer getLiveTimeline().getEvents().
  * @prop {object} tags Dict of room tags; the keys are the tag name and the values
  * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } }
  * @prop {object} accountData Dict of per-room account_data events; the keys are the
  * event type and the values are the events.
  * @prop {RoomState} oldState The state of the room at the time of the oldest
  * event in the live timeline. Present for backwards compatibility -
- * prefer getLiveTimeline().getState(true).
+ * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS).
  * @prop {RoomState} currentState The state of the room at the time of the
  * newest event in the timeline. Present for backwards compatibility -
- * prefer getLiveTimeline().getState(false).
+ * prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
  * @prop {RoomSummary} summary The room summary.
  * @prop {*} storageToken A token which a data store can use to remember
  * the state of the room.
  */
-function Room(roomId, opts) {
+function Room(roomId, client, myUserId, opts) {
     opts = opts || {};
     opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological";
 
+    this.reEmitter = new _ReEmitter2.default(this);
+
     if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) {
-        throw new Error(
-            "opts.pendingEventOrdering MUST be either 'chronological' or " +
-            "'detached'. Got: '" + opts.pendingEventOrdering + "'"
-        );
+        throw new Error("opts.pendingEventOrdering MUST be either 'chronological' or " + "'detached'. Got: '" + opts.pendingEventOrdering + "'");
     }
 
+    this.myUserId = myUserId;
     this.roomId = roomId;
     this.name = roomId;
     this.tags = {
         // $tagName: { $metadata: $value },
         // $tagName: { $metadata: $value },
     };
     this.accountData = {
         // $eventType: $event
@@ -147,224 +173,649 @@ function Room(roomId, opts) {
     // only receipts that came from the server, not synthesized ones
     this._realReceipts = {};
 
     this._notificationCounts = {};
 
     // all our per-room timeline sets. the first one is the unfiltered ones;
     // the subsequent ones are the filtered ones in no particular order.
     this._timelineSets = [new EventTimelineSet(this, opts)];
-    reEmit(this, this.getUnfilteredTimelineSet(),
-           ["Room.timeline", "Room.timelineReset"]);
+    this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]);
 
     this._fixUpLegacyTimelineFields();
 
     // any filtered timeline sets we're maintaining for this room
     this._filteredTimelineSets = {
         // filter_id: timelineSet
     };
 
     if (this._opts.pendingEventOrdering == "detached") {
         this._pendingEventList = [];
     }
+
+    // read by megolm; boolean value - null indicates "use global value"
+    this._blacklistUnverifiedDevices = null;
+    this._selfMembership = null;
+    this._summaryHeroes = null;
+    // awaited by getEncryptionTargetMembers while room members are loading
+
+    this._client = client;
+    if (!this._opts.lazyLoadMembers) {
+        this._membersPromise = Promise.resolve();
+    } else {
+        this._membersPromise = null;
+    }
 }
+
 utils.inherits(Room, EventEmitter);
 
 /**
+ * Gets the version of the room
+ * @returns {string} The version of the room, or null if it could not be determined
+ */
+Room.prototype.getVersion = function () {
+    const createEvent = this.currentState.getStateEvents("m.room.create", "");
+    if (!createEvent) {
+        _logger2.default.warn("Room " + this.room_id + " does not have an m.room.create event");
+        return '1';
+    }
+    const ver = createEvent.getContent()['room_version'];
+    if (ver === undefined) return '1';
+    return ver;
+};
+
+/**
+ * Determines whether this room needs to be upgraded to a new version
+ * @returns {string?} What version the room should be upgraded to, or null if
+ *     the room does not require upgrading at this time.
+ * @deprecated Use #getRecommendedVersion() instead
+ */
+Room.prototype.shouldUpgradeToVersion = function () {
+    // TODO: Remove this function.
+    // This makes assumptions about which versions are safe, and can easily
+    // be wrong. Instead, people are encouraged to use getRecommendedVersion
+    // which determines a safer value. This function doesn't use that function
+    // because this is not async-capable, and to avoid breaking the contract
+    // we're deprecating this.
+
+    if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) {
+        return KNOWN_SAFE_ROOM_VERSION;
+    }
+
+    return null;
+};
+
+/**
+ * Determines the recommended room version for the room. This returns an
+ * object with 3 properties: <code>version</code> as the new version the
+ * room should be upgraded to (may be the same as the current version);
+ * <code>needsUpgrade</code> to indicate if the room actually can be
+ * upgraded (ie: does the current version not match?); and <code>urgent</code>
+ * to indicate if the new version patches a vulnerability in a previous
+ * version.
+ * @returns {Promise<{version: string, needsUpgrade: bool, urgent: bool}>}
+ * Resolves to the version the room should be upgraded to.
+ */
+Room.prototype.getRecommendedVersion = async function () {
+    const capabilities = await this._client.getCapabilities();
+    let versionCap = capabilities["m.room_versions"];
+    if (!versionCap) {
+        versionCap = {
+            default: KNOWN_SAFE_ROOM_VERSION,
+            available: {}
+        };
+        for (const safeVer of SAFE_ROOM_VERSIONS) {
+            versionCap.available[safeVer] = "stable";
+        }
+    }
+
+    let result = this._checkVersionAgainstCapability(versionCap);
+    if (result.urgent && result.needsUpgrade) {
+        // Something doesn't feel right: we shouldn't need to update
+        // because the version we're on should be in the protocol's
+        // namespace. This usually means that the server was updated
+        // before the client was, making us think the newest possible
+        // room version is not stable. As a solution, we'll refresh
+        // the capability we're using to determine this.
+        _logger2.default.warn("Refreshing room version capability because the server looks " + "to be supporting a newer room version we don't know about.");
+
+        const caps = await this._client.getCapabilities(true);
+        versionCap = caps["m.room_versions"];
+        if (!versionCap) {
+            _logger2.default.warn("No room version capability - assuming upgrade required.");
+            return result;
+        } else {
+            result = this._checkVersionAgainstCapability(versionCap);
+        }
+    }
+
+    return result;
+};
+
+Room.prototype._checkVersionAgainstCapability = function (versionCap) {
+    const currentVersion = this.getVersion();
+    _logger2.default.log(`[${this.roomId}] Current version: ${currentVersion}`);
+    _logger2.default.log(`[${this.roomId}] Version capability: `, versionCap);
+
+    const result = {
+        version: currentVersion,
+        needsUpgrade: false,
+        urgent: false
+    };
+
+    // If the room is on the default version then nothing needs to change
+    if (currentVersion === versionCap.default) return result;
+
+    const stableVersions = Object.keys(versionCap.available).filter(v => versionCap.available[v] === 'stable');
+
+    // Check if the room is on an unstable version. We determine urgency based
+    // off the version being in the Matrix spec namespace or not (if the version
+    // is in the current namespace and unstable, the room is probably vulnerable).
+    if (!stableVersions.includes(currentVersion)) {
+        result.version = versionCap.default;
+        result.needsUpgrade = true;
+        result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g);
+        if (result.urgent) {
+            _logger2.default.warn(`URGENT upgrade required on ${this.roomId}`);
+        } else {
+            _logger2.default.warn(`Non-urgent upgrade required on ${this.roomId}`);
+        }
+        return result;
+    }
+
+    // The room is on a stable, but non-default, version by this point.
+    // No upgrade needed.
+    return result;
+};
+
+/**
+ * Determines whether the given user is permitted to perform a room upgrade
+ * @param {String} userId The ID of the user to test against
+ * @returns {bool} True if the given user is permitted to upgrade the room
+ */
+Room.prototype.userMayUpgradeRoom = function (userId) {
+    return this.currentState.maySendStateEvent("m.room.tombstone", userId);
+};
+
+/**
  * Get the list of pending sent events for this room
  *
  * @return {module:models/event.MatrixEvent[]} A list of the sent events
  * waiting for remote echo.
  *
  * @throws If <code>opts.pendingEventOrdering</code> was not 'detached'
  */
-Room.prototype.getPendingEvents = function() {
+Room.prototype.getPendingEvents = function () {
     if (this._opts.pendingEventOrdering !== "detached") {
-        throw new Error(
-            "Cannot call getPendingEventList with pendingEventOrdering == " +
-                this._opts.pendingEventOrdering);
+        throw new Error("Cannot call getPendingEvents with pendingEventOrdering == " + this._opts.pendingEventOrdering);
     }
 
     return this._pendingEventList;
 };
 
 /**
+ * Check whether the pending event list contains a given event by ID.
+ *
+ * @param {string} eventId The event ID to check for.
+ * @return {boolean}
+ * @throws If <code>opts.pendingEventOrdering</code> was not 'detached'
+ */
+Room.prototype.hasPendingEvent = function (eventId) {
+    if (this._opts.pendingEventOrdering !== "detached") {
+        throw new Error("Cannot call hasPendingEvent with pendingEventOrdering == " + this._opts.pendingEventOrdering);
+    }
+
+    return this._pendingEventList.some(event => event.getId() === eventId);
+};
+
+/**
  * Get the live unfiltered timeline for this room.
  *
  * @return {module:models/event-timeline~EventTimeline} live timeline
  */
-Room.prototype.getLiveTimeline = function() {
+Room.prototype.getLiveTimeline = function () {
     return this.getUnfilteredTimelineSet().getLiveTimeline();
 };
 
+/**
+ * @param {string} myUserId the user id for the logged in member
+ * @return {string} the membership type (join | leave | invite) for the logged in user
+ */
+Room.prototype.getMyMembership = function () {
+    return this._selfMembership;
+};
+
+/**
+ * If this room is a DM we're invited to,
+ * try to find out who invited us
+ * @return {string} user id of the inviter
+ */
+Room.prototype.getDMInviter = function () {
+    if (this.myUserId) {
+        const me = this.getMember(this.myUserId);
+        if (me) {
+            return me.getDMInviter();
+        }
+    }
+    if (this._selfMembership === "invite") {
+        // fall back to summary information
+        const memberCount = this.getInvitedAndJoinedMemberCount();
+        if (memberCount == 2 && this._summaryHeroes.length) {
+            return this._summaryHeroes[0];
+        }
+    }
+};
+
+/**
+ * Assuming this room is a DM room, tries to guess with which user.
+ * @return {string} user id of the other member (could be syncing user)
+ */
+Room.prototype.guessDMUserId = function () {
+    const me = this.getMember(this.myUserId);
+    if (me) {
+        const inviterId = me.getDMInviter();
+        if (inviterId) {
+            return inviterId;
+        }
+    }
+    // remember, we're assuming this room is a DM,
+    // so returning the first member we find should be fine
+    const hasHeroes = Array.isArray(this._summaryHeroes) && this._summaryHeroes.length;
+    if (hasHeroes) {
+        return this._summaryHeroes[0];
+    }
+    const members = this.currentState.getMembers();
+    const anyMember = members.find(m => m.userId !== this.myUserId);
+    if (anyMember) {
+        return anyMember.userId;
+    }
+    // it really seems like I'm the only user in the room
+    // so I probably created a room with just me in it
+    // and marked it as a DM. Ok then
+    return this.myUserId;
+};
+
+Room.prototype.getAvatarFallbackMember = function () {
+    const memberCount = this.getInvitedAndJoinedMemberCount();
+    if (memberCount > 2) {
+        return;
+    }
+    const hasHeroes = Array.isArray(this._summaryHeroes) && this._summaryHeroes.length;
+    if (hasHeroes) {
+        const availableMember = this._summaryHeroes.map(userId => {
+            return this.getMember(userId);
+        }).find(member => !!member);
+        if (availableMember) {
+            return availableMember;
+        }
+    }
+    const members = this.currentState.getMembers();
+    // could be different than memberCount
+    // as this includes left members
+    if (members.length <= 2) {
+        const availableMember = members.find(m => {
+            return m.userId !== this.myUserId;
+        });
+        if (availableMember) {
+            return availableMember;
+        }
+    }
+    // if all else fails, try falling back to a user,
+    // and create a one-off member for it
+    if (hasHeroes) {
+        const availableUser = this._summaryHeroes.map(userId => {
+            return this._client.getUser(userId);
+        }).find(user => !!user);
+        if (availableUser) {
+            const member = new RoomMember(this.roomId, availableUser.userId);
+            member.user = availableUser;
+            return member;
+        }
+    }
+};
+
+/**
+ * Sets the membership this room was received as during sync
+ * @param {string} membership join | leave | invite
+ */
+Room.prototype.updateMyMembership = function (membership) {
+    const prevMembership = this._selfMembership;
+    this._selfMembership = membership;
+    if (prevMembership !== membership) {
+        if (membership === "leave") {
+            this._cleanupAfterLeaving();
+        }
+        this.emit("Room.myMembership", this, membership, prevMembership);
+    }
+};
+
+Room.prototype._loadMembersFromServer = async function () {
+    const lastSyncToken = this._client.store.getSyncToken();
+    const queryString = utils.encodeParams({
+        not_membership: "leave",
+        at: lastSyncToken
+    });
+    const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, { $roomId: this.roomId });
+    const http = this._client._http;
+    const response = await http.authedRequest(undefined, "GET", path);
+    return response.chunk;
+};
+
+Room.prototype._loadMembers = async function () {
+    // were the members loaded from the server?
+    let fromServer = false;
+    let rawMembersEvents = await this._client.store.getOutOfBandMembers(this.roomId);
+    if (rawMembersEvents === null) {
+        fromServer = true;
+        rawMembersEvents = await this._loadMembersFromServer();
+        _logger2.default.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`);
+    }
+    const memberEvents = rawMembersEvents.map(this._client.getEventMapper());
+    return { memberEvents, fromServer };
+};
+
+/**
+ * Preloads the member list in case lazy loading
+ * of memberships is in use. Can be called multiple times,
+ * it will only preload once.
+ * @return {Promise} when preloading is done and
+ * accessing the members on the room will take
+ * all members in the room into account
+ */
+Room.prototype.loadMembersIfNeeded = function () {
+    if (this._membersPromise) {
+        return this._membersPromise;
+    }
+
+    // mark the state so that incoming messages while
+    // the request is in flight get marked as superseding
+    // the OOB members
+    this.currentState.markOutOfBandMembersStarted();
+
+    const inMemoryUpdate = this._loadMembers().then(result => {
+        this.currentState.setOutOfBandMembers(result.memberEvents);
+        // now the members are loaded, start to track the e2e devices if needed
+        if (this._client.isCryptoEnabled() && this._client.isRoomEncrypted(this.roomId)) {
+            this._client._crypto.trackRoomDevices(this.roomId);
+        }
+        return result.fromServer;
+    }).catch(err => {
+        // allow retries on fail
+        this._membersPromise = null;
+        this.currentState.markOutOfBandMembersFailed();
+        throw err;
+    });
+    // update members in storage, but don't wait for it
+    inMemoryUpdate.then(fromServer => {
+        if (fromServer) {
+            const oobMembers = this.currentState.getMembers().filter(m => m.isOutOfBand()).map(m => m.events.member.event);
+            _logger2.default.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`);
+            const store = this._client.store;
+            return store.setOutOfBandMembers(this.roomId, oobMembers)
+            // swallow any IDB error as we don't want to fail
+            // because of this
+            .catch(err => {
+                _logger2.default.log("LL: storing OOB room members failed, oh well", err);
+            });
+        }
+    }).catch(err => {
+        // as this is not awaited anywhere,
+        // at least show the error in the console
+        _logger2.default.error(err);
+    });
+
+    this._membersPromise = inMemoryUpdate;
+
+    return this._membersPromise;
+};
+
+/**
+ * Removes the lazily loaded members from storage if needed
+ */
+Room.prototype.clearLoadedMembersIfNeeded = async function () {
+    if (this._opts.lazyLoadMembers && this._membersPromise) {
+        await this.loadMembersIfNeeded();
+        await this._client.store.clearOutOfBandMembers(this.roomId);
+        this.currentState.clearOutOfBandMembers();
+        this._membersPromise = null;
+    }
+};
+
+/**
+ * called when sync receives this room in the leave section
+ * to do cleanup after leaving a room. Possibly called multiple times.
+ */
+Room.prototype._cleanupAfterLeaving = function () {
+    this.clearLoadedMembersIfNeeded().catch(err => {
+        _logger2.default.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`);
+        _logger2.default.log(err);
+    });
+};
 
 /**
  * Reset the live timeline of all timelineSets, and start new ones.
  *
  * <p>This is used when /sync returns a 'limited' timeline.
  *
  * @param {string=} backPaginationToken   token for back-paginating the new timeline
+ * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline,
+ * if absent or null, all timelines are reset, removing old ones (including the previous live
+ * timeline which would otherwise be unable to paginate forwards without this token).
+ * Removing just the old live timeline whilst preserving previous ones is not supported.
  */
-Room.prototype.resetLiveTimeline = function(backPaginationToken) {
-    for (var i = 0; i < this._timelineSets.length; i++) {
-        this._timelineSets[i].resetLiveTimeline(backPaginationToken);
+Room.prototype.resetLiveTimeline = function (backPaginationToken, forwardPaginationToken) {
+    for (let i = 0; i < this._timelineSets.length; i++) {
+        this._timelineSets[i].resetLiveTimeline(backPaginationToken, forwardPaginationToken);
     }
 
     this._fixUpLegacyTimelineFields();
 };
 
 /**
  * Fix up this.timeline, this.oldState and this.currentState
  *
  * @private
  */
-Room.prototype._fixUpLegacyTimelineFields = function() {
+Room.prototype._fixUpLegacyTimelineFields = function () {
     // maintain this.timeline as a reference to the live timeline,
     // and this.oldState and this.currentState as references to the
     // state at the start and end of that timeline. These are more
     // for backwards-compatibility than anything else.
     this.timeline = this.getLiveTimeline().getEvents();
-    this.oldState = this.getLiveTimeline()
-                        .getState(EventTimeline.BACKWARDS);
-    this.currentState = this.getLiveTimeline()
-                            .getState(EventTimeline.FORWARDS);
+    this.oldState = this.getLiveTimeline().getState(EventTimeline.BACKWARDS);
+    this.currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
+};
+
+/**
+ * Returns whether there are any devices in the room that are unverified
+ *
+ * Note: Callers should first check if crypto is enabled on this device. If it is
+ * disabled, then we aren't tracking room devices at all, so we can't answer this, and an
+ * error will be thrown.
+ *
+ * @return {bool} the result
+ */
+Room.prototype.hasUnverifiedDevices = async function () {
+    if (!this._client.isRoomEncrypted(this.roomId)) {
+        return false;
+    }
+    const e2eMembers = await this.getEncryptionTargetMembers();
+    for (const member of e2eMembers) {
+        const devices = await this._client.getStoredDevicesForUser(member.userId);
+        if (devices.some(device => device.isUnverified())) {
+            return true;
+        }
+    }
+    return false;
 };
 
 /**
  * Return the timeline sets for this room.
  * @return {EventTimelineSet[]} array of timeline sets for this room
  */
-Room.prototype.getTimelineSets = function() {
+Room.prototype.getTimelineSets = function () {
     return this._timelineSets;
 };
 
 /**
  * Helper to return the main unfiltered timeline set for this room
  * @return {EventTimelineSet} room's unfiltered timeline set
  */
-Room.prototype.getUnfilteredTimelineSet = function() {
+Room.prototype.getUnfilteredTimelineSet = function () {
     return this._timelineSets[0];
 };
 
 /**
  * Get the timeline which contains the given event from the unfiltered set, if any
  *
  * @param {string} eventId  event ID to look for
  * @return {?module:models/event-timeline~EventTimeline} timeline containing
  * the given event, or null if unknown
  */
-Room.prototype.getTimelineForEvent = function(eventId) {
+Room.prototype.getTimelineForEvent = function (eventId) {
     return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId);
 };
 
 /**
  * Add a new timeline to this room's unfiltered timeline set
  *
  * @return {module:models/event-timeline~EventTimeline} newly-created timeline
  */
-Room.prototype.addTimeline = function() {
+Room.prototype.addTimeline = function () {
     return this.getUnfilteredTimelineSet().addTimeline();
 };
 
 /**
  * Get an event which is stored in our unfiltered timeline set
  *
  * @param {string} eventId  event ID to look for
  * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown
  */
-Room.prototype.findEventById = function(eventId) {
+Room.prototype.findEventById = function (eventId) {
     return this.getUnfilteredTimelineSet().findEventById(eventId);
 };
 
 /**
  * Get one of the notification counts for this room
  * @param {String} type The type of notification count to get. default: 'total'
  * @return {Number} The notification count, or undefined if there is no count
  *                  for this type.
  */
-Room.prototype.getUnreadNotificationCount = function(type) {
+Room.prototype.getUnreadNotificationCount = function (type) {
     type = type || 'total';
     return this._notificationCounts[type];
 };
 
 /**
  * Set one of the notification counts for this room
  * @param {String} type The type of notification count to set.
  * @param {Number} count The new count
  */
-Room.prototype.setUnreadNotificationCount = function(type, count) {
+Room.prototype.setUnreadNotificationCount = function (type, count) {
     this._notificationCounts[type] = count;
 };
 
+Room.prototype.setSummary = function (summary) {
+    const heroes = summary["m.heroes"];
+    const joinedCount = summary["m.joined_member_count"];
+    const invitedCount = summary["m.invited_member_count"];
+    if (Number.isInteger(joinedCount)) {
+        this.currentState.setJoinedMemberCount(joinedCount);
+    }
+    if (Number.isInteger(invitedCount)) {
+        this.currentState.setInvitedMemberCount(invitedCount);
+    }
+    if (Array.isArray(heroes)) {
+        // be cautious about trusting server values,
+        // and make sure heroes doesn't contain our own id
+        // just to be sure
+        this._summaryHeroes = heroes.filter(userId => {
+            return userId !== this.myUserId;
+        });
+    }
+};
+
+/**
+ * Whether to send encrypted messages to devices within this room.
+ * @param {Boolean} value true to blacklist unverified devices, null
+ * to use the global value for this room.
+ */
+Room.prototype.setBlacklistUnverifiedDevices = function (value) {
+    this._blacklistUnverifiedDevices = value;
+};
+
+/**
+ * Whether to send encrypted messages to devices within this room.
+ * @return {Boolean} true if blacklisting unverified devices, null
+ * if the global value should be used for this room.
+ */
+Room.prototype.getBlacklistUnverifiedDevices = function () {
+    return this._blacklistUnverifiedDevices;
+};
+
 /**
  * Get the avatar URL for a room if one was set.
  * @param {String} baseUrl The homeserver base URL. See
  * {@link module:client~MatrixClient#getHomeserverUrl}.
  * @param {Number} width The desired width of the thumbnail.
  * @param {Number} height The desired height of the thumbnail.
  * @param {string} resizeMethod The thumbnail resize method to use, either
  * "crop" or "scale".
  * @param {boolean} allowDefault True to allow an identicon for this room if an
- * avatar URL wasn't explicitly set. Default: true.
+ * avatar URL wasn't explicitly set. Default: true. (Deprecated)
  * @return {?string} the avatar URL or null.
  */
-Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod,
-                                       allowDefault) {
-    var roomAvatarEvent = this.currentState.getStateEvents("m.room.avatar", "");
-    if (allowDefault === undefined) { allowDefault = true; }
+Room.prototype.getAvatarUrl = function (baseUrl, width, height, resizeMethod, allowDefault) {
+    const roomAvatarEvent = this.currentState.getStateEvents("m.room.avatar", "");
+    if (allowDefault === undefined) {
+        allowDefault = true;
+    }
     if (!roomAvatarEvent && !allowDefault) {
         return null;
     }
 
-    var mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
+    const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
     if (mainUrl) {
-        return ContentRepo.getHttpUriForMxc(
-            baseUrl, mainUrl, width, height, resizeMethod
-        );
-    }
-    else if (allowDefault) {
-        return ContentRepo.getIdenticonUri(
-            baseUrl, this.roomId, width, height
-        );
+        return ContentRepo.getHttpUriForMxc(baseUrl, mainUrl, width, height, resizeMethod);
+    } else if (allowDefault) {
+        return ContentRepo.getIdenticonUri(baseUrl, this.roomId, width, height);
     }
 
     return null;
 };
 
 /**
  * Get the aliases this room has according to the room's state
  * The aliases returned by this function may not necessarily
  * still point to this room.
  * @return {array} The room's alias as an array of strings
  */
-Room.prototype.getAliases = function() {
-    var alias_strings = [];
+Room.prototype.getAliases = function () {
+    const aliasStrings = [];
 
-    var alias_events = this.currentState.getStateEvents("m.room.aliases");
-    if (alias_events) {
-        for (var i = 0; i < alias_events.length; ++i) {
-            var alias_event = alias_events[i];
-            if (utils.isArray(alias_event.getContent().aliases)) {
-                Array.prototype.push.apply(
-                    alias_strings, alias_event.getContent().aliases
-                );
+    const aliasEvents = this.currentState.getStateEvents("m.room.aliases");
+    if (aliasEvents) {
+        for (let i = 0; i < aliasEvents.length; ++i) {
+            const aliasEvent = aliasEvents[i];
+            if (utils.isArray(aliasEvent.getContent().aliases)) {
+                Array.prototype.push.apply(aliasStrings, aliasEvent.getContent().aliases);
             }
         }
     }
-    return alias_strings;
+    return aliasStrings;
 };
 
 /**
  * Get this room's canonical alias
  * The alias returned by this function may not necessarily
  * still point to this room.
  * @return {?string} The room's canonical alias, or null if there is none
  */
-Room.prototype.getCanonicalAlias = function() {
-    var canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", "");
+Room.prototype.getCanonicalAlias = function () {
+    const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", "");
     if (canonicalAlias) {
         return canonicalAlias.getContent().alias;
     }
     return null;
 };
 
 /**
  * Add events to a timeline
@@ -380,120 +831,158 @@ Room.prototype.getCanonicalAlias = funct
  * @param {module:models/event-timeline~EventTimeline} timeline   timeline to
  *    add events to.
  *
  * @param {string=} paginationToken   token for the next batch of events
  *
  * @fires module:client~MatrixClient#event:"Room.timeline"
  *
  */
-Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
-                                              timeline, paginationToken) {
-    timeline.getTimelineSet().addEventsToTimeline(
-        events, toStartOfTimeline,
-        timeline, paginationToken
-    );
+Room.prototype.addEventsToTimeline = function (events, toStartOfTimeline, timeline, paginationToken) {
+    timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken);
 };
 
 /**
  * Get a member from the current room state.
  * @param {string} userId The user ID of the member.
  * @return {RoomMember} The member or <code>null</code>.
  */
- Room.prototype.getMember = function(userId) {
-    var member = this.currentState.members[userId];
-    if (!member) {
-        return null;
-    }
-    return member;
- };
+Room.prototype.getMember = function (userId) {
+    return this.currentState.getMember(userId);
+};
 
 /**
  * Get a list of members whose membership state is "join".
  * @return {RoomMember[]} A list of currently joined members.
  */
- Room.prototype.getJoinedMembers = function() {
+Room.prototype.getJoinedMembers = function () {
     return this.getMembersWithMembership("join");
- };
+};
+
+/**
+ * Returns the number of joined members in this room
+ * This method caches the result.
+ * This is a wrapper around the method of the same name in roomState, returning
+ * its result for the room's current state.
+ * @return {integer} The number of members in this room whose membership is 'join'
+ */
+Room.prototype.getJoinedMemberCount = function () {
+    return this.currentState.getJoinedMemberCount();
+};
+
+/**
+ * Returns the number of invited members in this room
+ * @return {integer} The number of members in this room whose membership is 'invite'
+ */
+Room.prototype.getInvitedMemberCount = function () {
+    return this.currentState.getInvitedMemberCount();
+};
+
+/**
+ * Returns the number of invited + joined members in this room
+ * @return {integer} The number of members in this room whose membership is 'invite' or 'join'
+ */
+Room.prototype.getInvitedAndJoinedMemberCount = function () {
+    return this.getInvitedMemberCount() + this.getJoinedMemberCount();
+};
 
 /**
  * Get a list of members with given membership state.
  * @param {string} membership The membership state.
  * @return {RoomMember[]} A list of members with the given membership state.
  */
- Room.prototype.getMembersWithMembership = function(membership) {
-    return utils.filter(this.currentState.getMembers(), function(m) {
+Room.prototype.getMembersWithMembership = function (membership) {
+    return utils.filter(this.currentState.getMembers(), function (m) {
         return m.membership === membership;
     });
- };
+};
+
+/**
+ * Get a list of members we should be encrypting for in this room
+ * @return {Promise<RoomMember[]>} A list of members who
+ * we should encrypt messages for in this room.
+ */
+Room.prototype.getEncryptionTargetMembers = async function () {
+    await this.loadMembersIfNeeded();
+    let members = this.getMembersWithMembership("join");
+    if (this.shouldEncryptForInvitedMembers()) {
+        members = members.concat(this.getMembersWithMembership("invite"));
+    }
+    return members;
+};
 
- /**
-  * Get the default room name (i.e. what a given user would see if the
-  * room had no m.room.name)
-  * @param {string} userId The userId from whose perspective we want
-  * to calculate the default name
-  * @return {string} The default room name
-  */
- Room.prototype.getDefaultRoomName = function(userId) {
+/**
+ * Determine whether we should encrypt messages for invited users in this room
+ * @return {boolean} if we should encrypt messages for invited users
+ */
+Room.prototype.shouldEncryptForInvitedMembers = function () {
+    const ev = this.currentState.getStateEvents("m.room.history_visibility", "");
+    return ev && ev.getContent() && ev.getContent().history_visibility !== "joined";
+};
+
+/**
+ * Get the default room name (i.e. what a given user would see if the
+ * room had no m.room.name)
+ * @param {string} userId The userId from whose perspective we want
+ * to calculate the default name
+ * @return {string} The default room name
+ */
+Room.prototype.getDefaultRoomName = function (userId) {
     return calculateRoomName(this, userId, true);
- };
-
+};
 
- /**
- * Check if the given user_id has the given membership state.
- * @param {string} userId The user ID to check.
- * @param {string} membership The membership e.g. <code>'join'</code>
- * @return {boolean} True if this user_id has the given membership state.
- */
- Room.prototype.hasMembershipState = function(userId, membership) {
-    var member = this.getMember(userId);
+/**
+* Check if the given user_id has the given membership state.
+* @param {string} userId The user ID to check.
+* @param {string} membership The membership e.g. <code>'join'</code>
+* @return {boolean} True if this user_id has the given membership state.
+*/
+Room.prototype.hasMembershipState = function (userId, membership) {
+    const member = this.getMember(userId);
     if (!member) {
         return false;
     }
     return member.membership === membership;
- };
+};
 
 /**
  * Add a timelineSet for this room with the given filter
  * @param {Filter} filter  The filter to be applied to this timelineSet
  * @return {EventTimelineSet}  The timelineSet
  */
-Room.prototype.getOrCreateFilteredTimelineSet = function(filter) {
+Room.prototype.getOrCreateFilteredTimelineSet = function (filter) {
     if (this._filteredTimelineSets[filter.filterId]) {
         return this._filteredTimelineSets[filter.filterId];
     }
-    var opts = Object.assign({ filter: filter }, this._opts);
-    var timelineSet = new EventTimelineSet(this, opts);
-    reEmit(this, timelineSet, ["Room.timeline", "Room.timelineReset"]);
+    const opts = Object.assign({ filter: filter }, this._opts);
+    const timelineSet = new EventTimelineSet(this, opts);
+    this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]);
     this._filteredTimelineSets[filter.filterId] = timelineSet;
     this._timelineSets.push(timelineSet);
 
     // populate up the new timelineSet with filtered events from our live
     // unfiltered timeline.
     //
     // XXX: This is risky as our timeline
     // may have grown huge and so take a long time to filter.
     // see https://github.com/vector-im/vector-web/issues/2109
 
-    var unfilteredLiveTimeline = this.getLiveTimeline();
+    const unfilteredLiveTimeline = this.getLiveTimeline();
 
-    unfilteredLiveTimeline.getEvents().forEach(function(event) {
+    unfilteredLiveTimeline.getEvents().forEach(function (event) {
         timelineSet.addLiveEvent(event);
     });
 
     // find the earliest unfiltered timeline
-    var timeline = unfilteredLiveTimeline;
+    let timeline = unfilteredLiveTimeline;
     while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) {
         timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
     }
 
-    timelineSet.getLiveTimeline().setPaginationToken(
-        timeline.getPaginationToken(EventTimeline.BACKWARDS),
-        EventTimeline.BACKWARDS
-    );
+    timelineSet.getLiveTimeline().setPaginationToken(timeline.getPaginationToken(EventTimeline.BACKWARDS), EventTimeline.BACKWARDS);
 
     // alternatively, we could try to do something like this to try and re-paginate
     // in the filtered events from nothing, but Mark says it's an abuse of the API
     // to do so:
     //
     // timelineSet.resetLiveTimeline(
     //      unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS)
     // );
@@ -501,43 +990,51 @@ Room.prototype.getOrCreateFilteredTimeli
     return timelineSet;
 };
 
 /**
  * Forget the timelineSet for this room with the given filter
  *
  * @param {Filter} filter  the filter whose timelineSet is to be forgotten
  */
-Room.prototype.removeFilteredTimelineSet = function(filter) {
-    var timelineSet = this._filteredTimelineSets[filter.filterId];
+Room.prototype.removeFilteredTimelineSet = function (filter) {
+    const timelineSet = this._filteredTimelineSets[filter.filterId];
     delete this._filteredTimelineSets[filter.filterId];
-    var i = this._timelineSets.indexOf(timelineSet);
+    const i = this._timelineSets.indexOf(timelineSet);
     if (i > -1) {
         this._timelineSets.splice(i, 1);
     }
 };
 
 /**
  * Add an event to the end of this room's live timelines. Will fire
  * "Room.timeline".
  *
  * @param {MatrixEvent} event Event to be added
  * @param {string?} duplicateStrategy 'ignore' or 'replace'
  * @fires module:client~MatrixClient#event:"Room.timeline"
  * @private
  */
-Room.prototype._addLiveEvent = function(event, duplicateStrategy) {
-    var i;
-    if (event.getType() === "m.room.redaction") {
-        var redactId = event.event.redacts;
+Room.prototype._addLiveEvent = function (event, duplicateStrategy) {
+    if (event.isRedaction()) {
+        const redactId = event.event.redacts;
 
         // if we know about this event, redact its contents now.
-        var redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId);
+        const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId);
         if (redactedEvent) {
             redactedEvent.makeRedacted(event);
+
+            // If this is in the current state, replace it with the redacted version
+            if (redactedEvent.getStateKey()) {
+                const currentStateEvent = this.currentState.getStateEvents(redactedEvent.getType(), redactedEvent.getStateKey());
+                if (currentStateEvent.getId() === redactedEvent.getId()) {
+                    this.currentState.setStateEvents([redactedEvent]);
+                }
+            }
+
             this.emit("Room.redaction", event, this);
 
             // TODO: we stash user displaynames (among other things) in
             // RoomMember objects which are then attached to other events
             // (in the sender and target fields). We should get those
             // RoomMember objects to update themselves when the events that
             // they are based on are changed.
         }
@@ -545,47 +1042,45 @@ Room.prototype._addLiveEvent = function(
         // FIXME: apply redactions to notification list
 
         // NB: We continue to add the redaction event to the timeline so
         // clients can say "so and so redacted an event" if they wish to. Also
         // this may be needed to trigger an update.
     }
 
     if (event.getUnsigned().transaction_id) {
-        var existingEvent = this._txnToEvent[event.getUnsigned().transaction_id];
+        const existingEvent = this._txnToEvent[event.getUnsigned().transaction_id];
         if (existingEvent) {
             // remote echo of an event we sent earlier
             this._handleRemoteEcho(event, existingEvent);
             return;
         }
     }
 
     // add to our timeline sets
-    for (i = 0; i < this._timelineSets.length; i++) {
+    for (let i = 0; i < this._timelineSets.length; i++) {
         this._timelineSets[i].addLiveEvent(event, duplicateStrategy);
     }
 
     // synthesize and inject implicit read receipts
     // Done after adding the event because otherwise the app would get a read receipt
     // pointing to an event that wasn't yet in the timeline
-    if (event.sender) {
-        this.addReceipt(synthesizeReceipt(
-            event.sender.userId, event, "m.read"
-        ), true);
+    // Don't synthesize RR for m.room.redaction as this causes the RR to go missing.
+    if (event.sender && event.getType() !== "m.room.redaction") {
+        this.addReceipt(synthesizeReceipt(event.sender.userId, event, "m.read"), true);
 
         // Any live events from a user could be taken as implicit
         // presence information: evidence that they are currently active.
         // ...except in a world where we use 'user.currentlyActive' to reduce
         // presence spam, this isn't very useful - we'll get a transition when
         // they are no longer currently active anyway. So don't bother to
         // reset the lastActiveAgo and lastPresenceTs from the RoomState's user.
     }
 };
 
-
 /**
  * Add a pending outgoing event to this room.
  *
  * <p>The event is added to either the pendingEventList, or the live timeline,
  * depending on the setting of opts.pendingEventOrdering.
  *
  * <p>This is an internal method, intended for use by MatrixClient.
  *
@@ -593,207 +1088,243 @@ Room.prototype._addLiveEvent = function(
  *
  * @param {string} txnId   Transaction id for this outgoing event
  *
  * @fires module:client~MatrixClient#event:"Room.localEchoUpdated"
  *
  * @throws if the event doesn't have status SENDING, or we aren't given a
  * unique transaction id.
  */
-Room.prototype.addPendingEvent = function(event, txnId) {
+Room.prototype.addPendingEvent = function (event, txnId) {
     if (event.status !== EventStatus.SENDING) {
-        throw new Error("addPendingEvent called on an event with status " +
-                        event.status);
+        throw new Error("addPendingEvent called on an event with status " + event.status);
     }
 
     if (this._txnToEvent[txnId]) {
-        throw new Error("addPendingEvent called on an event with known txnId " +
-                        txnId);
+        throw new Error("addPendingEvent called on an event with known txnId " + txnId);
     }
 
     // call setEventMetadata to set up event.sender etc
     // as event is shared over all timelineSets, we set up its metadata based
     // on the unfiltered timelineSet.
-    EventTimeline.setEventMetadata(
-        event,
-        this.getLiveTimeline().getState(EventTimeline.FORWARDS),
-        false
-    );
+    EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS), false);
 
     this._txnToEvent[txnId] = event;
 
     if (this._opts.pendingEventOrdering == "detached") {
+        if (this._pendingEventList.some(e => e.status === EventStatus.NOT_SENT)) {
+            _logger2.default.warn("Setting event as NOT_SENT due to messages in the same state");
+            event.setStatus(EventStatus.NOT_SENT);
+        }
         this._pendingEventList.push(event);
+
+        if (event.isRelation()) {
+            // For pending events, add them to the relations collection immediately.
+            // (The alternate case below already covers this as part of adding to
+            // the timeline set.)
+            this._aggregateNonLiveRelation(event);
+        }
+
+        if (event.isRedaction()) {
+            const redactId = event.event.redacts;
+            let redactedEvent = this._pendingEventList && this._pendingEventList.find(e => e.getId() === redactId);
+            if (!redactedEvent) {
+                redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId);
+            }
+            if (redactedEvent) {
+                redactedEvent.markLocallyRedacted(event);
+                this.emit("Room.redaction", event, this);
+            }
+        }
     } else {
-        for (var i = 0; i < this._timelineSets.length; i++) {
-            var timelineSet = this._timelineSets[i];
+        for (let i = 0; i < this._timelineSets.length; i++) {
+            const timelineSet = this._timelineSets[i];
             if (timelineSet.getFilter()) {
-                if (this._filter.filterRoomTimeline([event]).length) {
-                    timelineSet.addEventToTimeline(event,
-                        timelineSet.getLiveTimeline(), false);
+                if (timelineSet.getFilter().filterRoomTimeline([event]).length) {
+                    timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false);
                 }
-            }
-            else {
-                timelineSet.addEventToTimeline(event,
-                    timelineSet.getLiveTimeline(), false);
+            } else {
+                timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false);
             }
         }
     }
 
     this.emit("Room.localEchoUpdated", event, this, null, null);
 };
+/**
+ * Used to aggregate the local echo for a relation, and also
+ * for re-applying a relation after it's redaction has been cancelled,
+ * as the local echo for the redaction of the relation would have
+ * un-aggregated the relation. Note that this is different from regular messages,
+ * which are just kept detached for their local echo.
+ *
+ * Also note that live events are aggregated in the live EventTimelineSet.
+ * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated.
+ */
+Room.prototype._aggregateNonLiveRelation = function (event) {
+    // TODO: We should consider whether this means it would be a better
+    // design to lift the relations handling up to the room instead.
+    for (let i = 0; i < this._timelineSets.length; i++) {
+        const timelineSet = this._timelineSets[i];
+        if (timelineSet.getFilter()) {
+            if (timelineSet.getFilter().filterRoomTimeline([event]).length) {
+                timelineSet.aggregateRelations(event);
+            }
+        } else {
+            timelineSet.aggregateRelations(event);
+        }
+    }
+};
 
 /**
  * Deal with the echo of a message we sent.
  *
  * <p>We move the event to the live timeline if it isn't there already, and
  * update it.
  *
  * @param {module:models/event.MatrixEvent} remoteEvent   The event received from
  *    /sync
  * @param {module:models/event.MatrixEvent} localEvent    The local echo, which
  *    should be either in the _pendingEventList or the timeline.
  *
  * @fires module:client~MatrixClient#event:"Room.localEchoUpdated"
  * @private
  */
-Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) {
-    var oldEventId = localEvent.getId();
-    var newEventId = remoteEvent.getId();
-    var oldStatus = localEvent.status;
+Room.prototype._handleRemoteEcho = function (remoteEvent, localEvent) {
+    const oldEventId = localEvent.getId();
+    const newEventId = remoteEvent.getId();
+    const oldStatus = localEvent.status;
 
     // no longer pending
-    delete this._txnToEvent[remoteEvent.transaction_id];
+    delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id];
 
     // if it's in the pending list, remove it
     if (this._pendingEventList) {
-        utils.removeElement(
-            this._pendingEventList,
-            function(ev) { return ev.getId() == oldEventId; },
-            false
-        );
+        utils.removeElement(this._pendingEventList, function (ev) {
+            return ev.getId() == oldEventId;
+        }, false);
     }
 
     // replace the event source (this will preserve the plaintext payload if
     // any, which is good, because we don't want to try decoding it again).
-    localEvent.event = remoteEvent.event;
+    localEvent.handleRemoteEcho(remoteEvent.event);
 
-    // successfully sent.
-    localEvent.status = null;
-
-    for (var i = 0; i < this._timelineSets.length; i++) {
-        var timelineSet = this._timelineSets[i];
+    for (let i = 0; i < this._timelineSets.length; i++) {
+        const timelineSet = this._timelineSets[i];
 
         // if it's already in the timeline, update the timeline map. If it's not, add it.
         timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
     }
 
-    this.emit("Room.localEchoUpdated", localEvent, this,
-              oldEventId, oldStatus);
+    this.emit("Room.localEchoUpdated", localEvent, this, oldEventId, oldStatus);
 };
 
 /* a map from current event status to a list of allowed next statuses
  */
-var ALLOWED_TRANSITIONS = {};
+const ALLOWED_TRANSITIONS = {};
 
-ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [
-    EventStatus.SENDING,
-    EventStatus.NOT_SENT,
-];
+ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [EventStatus.SENDING, EventStatus.NOT_SENT];
+
+ALLOWED_TRANSITIONS[EventStatus.SENDING] = [EventStatus.ENCRYPTING, EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.SENT];
 
-ALLOWED_TRANSITIONS[EventStatus.SENDING] = [
-    EventStatus.ENCRYPTING,
-    EventStatus.QUEUED,
-    EventStatus.NOT_SENT,
-    EventStatus.SENT,
-];
+ALLOWED_TRANSITIONS[EventStatus.QUEUED] = [EventStatus.SENDING, EventStatus.CANCELLED];
+
+ALLOWED_TRANSITIONS[EventStatus.SENT] = [];
 
-ALLOWED_TRANSITIONS[EventStatus.QUEUED] =
-    [EventStatus.SENDING, EventStatus.CANCELLED];
-
-ALLOWED_TRANSITIONS[EventStatus.SENT] =
-    [];
+ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED];
 
-ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] =
-    [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED];
-
-ALLOWED_TRANSITIONS[EventStatus.CANCELLED] =
-    [];
+ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = [];
 
 /**
  * Update the status / event id on a pending event, to reflect its transmission
  * progress.
  *
  * <p>This is an internal method.
  *
  * @param {MatrixEvent} event      local echo event
  * @param {EventStatus} newStatus  status to assign
  * @param {string} newEventId      new event id to assign. Ignored unless
  *    newStatus == EventStatus.SENT.
  * @fires module:client~MatrixClient#event:"Room.localEchoUpdated"
  */
-Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) {
+Room.prototype.updatePendingEvent = function (event, newStatus, newEventId) {
+    _logger2.default.log(`setting pendingEvent status to ${newStatus} in ${event.getRoomId()}`);
+
     // if the message was sent, we expect an event id
     if (newStatus == EventStatus.SENT && !newEventId) {
-        throw new Error("updatePendingEvent called with status=SENT, " +
-                        "but no new event id");
+        throw new Error("updatePendingEvent called with status=SENT, " + "but no new event id");
     }
 
     // SENT races against /sync, so we have to special-case it.
     if (newStatus == EventStatus.SENT) {
-        var timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId);
+        const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId);
         if (timeline) {
             // we've already received the event via the event stream.
             // nothing more to do here.
             return;
         }
     }
 
-    var oldStatus = event.status;
-    var oldEventId = event.getId();
+    const oldStatus = event.status;
+    const oldEventId = event.getId();
 
     if (!oldStatus) {
-        throw new Error("updatePendingEventStatus called on an event which is " +
-                        "not a local echo.");
+        throw new Error("updatePendingEventStatus called on an event which is " + "not a local echo.");
     }
 
-    var allowed = ALLOWED_TRANSITIONS[oldStatus];
+    const allowed = ALLOWED_TRANSITIONS[oldStatus];
     if (!allowed || allowed.indexOf(newStatus) < 0) {
-        throw new Error("Invalid EventStatus transition " + oldStatus + "->" +
-                        newStatus);
+        throw new Error("Invalid EventStatus transition " + oldStatus + "->" + newStatus);
     }
 
-    event.status = newStatus;
+    event.setStatus(newStatus);
 
     if (newStatus == EventStatus.SENT) {
         // update the event id
-        event.event.event_id = newEventId;
+        event.replaceLocalEventId(newEventId);
 
         // if the event was already in the timeline (which will be the case if
         // opts.pendingEventOrdering==chronological), we need to update the
         // timeline map.
-        for (var i = 0; i < this._timelineSets.length; i++) {
+        for (let i = 0; i < this._timelineSets.length; i++) {
             this._timelineSets[i].replaceEventId(oldEventId, newEventId);
         }
-    }
-    else if (newStatus == EventStatus.CANCELLED) {
+    } else if (newStatus == EventStatus.CANCELLED) {
         // remove it from the pending event list, or the timeline.
         if (this._pendingEventList) {
-            utils.removeElement(
-                this._pendingEventList,
-                function(ev) { return ev.getId() == oldEventId; },
-                false
-            );
+            const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId);
+            if (idx !== -1) {
+                const [removedEvent] = this._pendingEventList.splice(idx, 1);
+                if (removedEvent.isRedaction()) {
+                    this._revertRedactionLocalEcho(removedEvent);
+                }
+            }
         }
         this.removeEvent(oldEventId);
     }
 
-    this.emit("Room.localEchoUpdated", event, this, event.getId(), oldStatus);
+    this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus);
 };
 
+Room.prototype._revertRedactionLocalEcho = function (redactionEvent) {
+    const redactId = redactionEvent.event.redacts;
+    if (!redactId) {
+        return;
+    }
+    const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId);
+    if (redactedEvent) {
+        redactedEvent.unmarkLocallyRedacted();
+        // re-render after undoing redaction
+        this.emit("Room.redactionCancelled", redactionEvent, this);
+        // reapply relation now redaction failed
+        if (redactedEvent.isRelation()) {
+            this._aggregateNonLiveRelation(redactedEvent);
+        }
+    }
+};
 
 /**
  * Add some events to this room. This can include state events, message
  * events and typing notifications. These events are treated as "live" so
  * they will go to the end of the timeline.
  *
  * @param {MatrixEvent[]} events A list of events to add.
  *
@@ -801,198 +1332,221 @@ Room.prototype.updatePendingEvent = func
  * timeline only. If this is 'replace' then if a duplicate is encountered, the
  * event passed to this function will replace the existing event in the
  * timeline. If this is not specified, or is 'ignore', then the event passed to
  * this function will be ignored entirely, preserving the existing event in the
  * timeline. Events are identical based on their event ID <b>only</b>.
  *
  * @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'.
  */
-Room.prototype.addLiveEvents = function(events, duplicateStrategy) {
-    var i;
+Room.prototype.addLiveEvents = function (events, duplicateStrategy) {
+    let i;
     if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
         throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
     }
 
     // sanity check that the live timeline is still live
     for (i = 0; i < this._timelineSets.length; i++) {
-        var liveTimeline = this._timelineSets[i].getLiveTimeline();
+        const liveTimeline = this._timelineSets[i].getLiveTimeline();
         if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
-            throw new Error(
-                "live timeline " + i + " is no longer live - it has a pagination token " +
-                "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")"
-            );
+            throw new Error("live timeline " + i + " is no longer live - it has a pagination token " + "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")");
         }
         if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
-            throw new Error(
-                "live timeline " + i + " is no longer live - " +
-                "it has a neighbouring timeline"
-            );
+            throw new Error("live timeline " + i + " is no longer live - " + "it has a neighbouring timeline");
         }
     }
 
     for (i = 0; i < events.length; i++) {
-        if (events[i].getType() === "m.typing") {
-            this.currentState.setTypingEvent(events[i]);
-        }
-        else if (events[i].getType() === "m.receipt") {
-            this.addReceipt(events[i]);
-        }
-        // N.B. account_data is added directly by /sync to avoid
-        // having to maintain an event.isAccountData() here
-        else {
-            // TODO: We should have a filter to say "only add state event
-            // types X Y Z to the timeline".
-            this._addLiveEvent(events[i], duplicateStrategy);
-        }
+        // TODO: We should have a filter to say "only add state event
+        // types X Y Z to the timeline".
+        this._addLiveEvent(events[i], duplicateStrategy);
+    }
+};
+
+/**
+ * Adds/handles ephemeral events such as typing notifications and read receipts.
+ * @param {MatrixEvent[]} events A list of events to process
+ */
+Room.prototype.addEphemeralEvents = function (events) {
+    for (const event of events) {
+        if (event.getType() === 'm.typing') {
+            this.currentState.setTypingEvent(event);
+        } else if (event.getType() === 'm.receipt') {
+            this.addReceipt(event);
+        } // else ignore - life is too short for us to care about these events
     }
 };
 
 /**
  * Removes events from this room.
- * @param {String[]} event_ids A list of event_ids to remove.
+ * @param {String[]} eventIds A list of eventIds to remove.
  */
-Room.prototype.removeEvents = function(event_ids) {
-    for (var i = 0; i < event_ids.length; ++i) {
-        this.removeEvent(event_ids[i]);
+Room.prototype.removeEvents = function (eventIds) {
+    for (let i = 0; i < eventIds.length; ++i) {
+        this.removeEvent(eventIds[i]);
     }
 };
 
 /**
  * Removes a single event from this room.
  *
  * @param {String} eventId  The id of the event to remove
  *
  * @return {bool} true if the event was removed from any of the room's timeline sets
  */
-Room.prototype.removeEvent = function(eventId) {
-    var removedAny = false;
-    for (var i = 0; i < this._timelineSets.length; i++) {
-        var removed = this._timelineSets[i].removeEvent(eventId);
+Room.prototype.removeEvent = function (eventId) {
+    let removedAny = false;
+    for (let i = 0; i < this._timelineSets.length; i++) {
+        const removed = this._timelineSets[i].removeEvent(eventId);
         if (removed) {
+            if (removed.isRedaction()) {
+                this._revertRedactionLocalEcho(removed);
+            }
             removedAny = true;
         }
     }
     return removedAny;
 };
 
-
 /**
  * Recalculate various aspects of the room, including the room name and
  * room summary. Call this any time the room's current state is modified.
  * May fire "Room.name" if the room name is updated.
- * @param {string} userId The client's user ID.
  * @fires module:client~MatrixClient#event:"Room.name"
  */
-Room.prototype.recalculate = function(userId) {
+Room.prototype.recalculate = function () {
     // set fake stripped state events if this is an invite room so logic remains
     // consistent elsewhere.
-    var self = this;
-    var membershipEvent = this.currentState.getStateEvents(
-        "m.room.member", userId
-    );
+    const self = this;
+    const membershipEvent = this.currentState.getStateEvents("m.room.member", this.myUserId);
     if (membershipEvent && membershipEvent.getContent().membership === "invite") {
-        var strippedStateEvents = membershipEvent.event.invite_room_state || [];
-        utils.forEach(strippedStateEvents, function(strippedEvent) {
-            var existingEvent = self.currentState.getStateEvents(
-                strippedEvent.type, strippedEvent.state_key
-            );
+        const strippedStateEvents = membershipEvent.event.invite_room_state || [];
+        utils.forEach(strippedStateEvents, function (strippedEvent) {
+            const existingEvent = self.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key);
             if (!existingEvent) {
                 // set the fake stripped event instead
                 self.currentState.setStateEvents([new MatrixEvent({
                     type: strippedEvent.type,
                     state_key: strippedEvent.state_key,
                     content: strippedEvent.content,
                     event_id: "$fake" + Date.now(),
                     room_id: self.roomId,
-                    user_id: userId // technically a lie
+                    user_id: self.myUserId // technically a lie
                 })]);
             }
         });
     }
 
-
-
-    var oldName = this.name;
-    this.name = calculateRoomName(this, userId);
+    const oldName = this.name;
+    this.name = calculateRoomName(this, this.myUserId);
     this.summary = new RoomSummary(this.roomId, {
         title: this.name
     });
 
     if (oldName !== this.name) {
         this.emit("Room.name", this);
     }
 };
 
-
 /**
  * Get a list of user IDs who have <b>read up to</b> the given event.
  * @param {MatrixEvent} event the event to get read receipts for.
  * @return {String[]} A list of user IDs.
  */
-Room.prototype.getUsersReadUpTo = function(event) {
-    return this.getReceiptsForEvent(event).filter(function(receipt) {
+Room.prototype.getUsersReadUpTo = function (event) {
+    return this.getReceiptsForEvent(event).filter(function (receipt) {
         return receipt.type === "m.read";
-    }).map(function(receipt) {
+    }).map(function (receipt) {
         return receipt.userId;
     });
 };
 
 /**
  * Get the ID of the event that a given user has read up to, or null if we
  * have received no read receipts from them.
  * @param {String} userId The user ID to get read receipt event ID for
  * @param {Boolean} ignoreSynthesized If true, return only receipts that have been
  *                                    sent by the server, not implicit ones generated
  *                                    by the JS SDK.
  * @return {String} ID of the latest event that the given user has read, or null.
  */
-Room.prototype.getEventReadUpTo = function(userId, ignoreSynthesized) {
-    var receipts = this._receipts;
+Room.prototype.getEventReadUpTo = function (userId, ignoreSynthesized) {
+    let receipts = this._receipts;
     if (ignoreSynthesized) {
         receipts = this._realReceipts;
     }
 
-    if (
-        receipts["m.read"] === undefined ||
-        receipts["m.read"][userId] === undefined
-    ) {
+    if (receipts["m.read"] === undefined || receipts["m.read"][userId] === undefined) {
         return null;
     }
 
     return receipts["m.read"][userId].eventId;
 };
 
 /**
+ * Determines if the given user has read a particular event ID with the known
+ * history of the room. This is not a definitive check as it relies only on
+ * what is available to the room at the time of execution.
+ * @param {String} userId The user ID to check the read state of.
+ * @param {String} eventId The event ID to check if the user read.
+ * @returns {Boolean} True if the user has read the event, false otherwise.
+ */
+Room.prototype.hasUserReadEvent = function (userId, eventId) {
+    const readUpToId = this.getEventReadUpTo(userId, false);
+    if (readUpToId === eventId) return true;
+
+    if (this.timeline.length && this.timeline[this.timeline.length - 1].getSender() && this.timeline[this.timeline.length - 1].getSender() === userId) {
+        // It doesn't matter where the event is in the timeline, the user has read
+        // it because they've sent the latest event.
+        return true;
+    }
+
+    for (let i = this.timeline.length - 1; i >= 0; --i) {
+        const ev = this.timeline[i];
+
+        // If we encounter the target event first, the user hasn't read it
+        // however if we encounter the readUpToId first then the user has read
+        // it. These rules apply because we're iterating bottom-up.
+        if (ev.getId() === eventId) return false;
+        if (ev.getId() === readUpToId) return true;
+    }
+
+    // We don't know if the user has read it, so assume not.
+    return false;
+};
+
+/**
  * Get a list of receipts for the given event.
  * @param {MatrixEvent} event the event to get receipts for
  * @return {Object[]} A list of receipts with a userId, type and data keys or
  * an empty list.
  */
-Room.prototype.getReceiptsForEvent = function(event) {
+Room.prototype.getReceiptsForEvent = function (event) {
     return this._receiptCacheByEventId[event.getId()] || [];
 };
 
 /**
  * Add a receipt event to the room.
  * @param {MatrixEvent} event The m.receipt event.
  * @param {Boolean} fake True if this event is implicit
  */
-Room.prototype.addReceipt = function(event, fake) {
+Room.prototype.addReceipt = function (event, fake) {
     // event content looks like:
     // content: {
     //   $event_id: {
     //     $receipt_type: {
     //       $user_id: {
     //         ts: $timestamp
     //       }
     //     }
     //   }
     // }
-    if (fake === undefined) { fake = false; }
+    if (fake === undefined) {
+        fake = false;
+    }
     if (!fake) {
         this._addReceiptsToStructure(event, this._realReceipts);
         // we don't bother caching real receipts by event ID
         // as there's nothing that would read it.
     }
     this._addReceiptsToStructure(event, this._receipts);
     this._receiptCacheByEventId = this._buildReceiptCache(this._receipts);
 
@@ -1001,39 +1555,37 @@ Room.prototype.addReceipt = function(eve
     this.emit("Room.receipt", event, this);
 };
 
 /**
  * Add a receipt event to the room.
  * @param {MatrixEvent} event The m.receipt event.
  * @param {Object} receipts The object to add receipts to
  */
-Room.prototype._addReceiptsToStructure = function(event, receipts) {
-    var self = this;
-    utils.keys(event.getContent()).forEach(function(eventId) {
-        utils.keys(event.getContent()[eventId]).forEach(function(receiptType) {
-            utils.keys(event.getContent()[eventId][receiptType]).forEach(
-            function(userId) {
-                var receipt = event.getContent()[eventId][receiptType][userId];
+Room.prototype._addReceiptsToStructure = function (event, receipts) {
+    const self = this;
+    utils.keys(event.getContent()).forEach(function (eventId) {
+        utils.keys(event.getContent()[eventId]).forEach(function (receiptType) {
+            utils.keys(event.getContent()[eventId][receiptType]).forEach(function (userId) {
+                const receipt = event.getContent()[eventId][receiptType][userId];
 
                 if (!receipts[receiptType]) {
                     receipts[receiptType] = {};
                 }
 
-                var existingReceipt = receipts[receiptType][userId];
+                const existingReceipt = receipts[receiptType][userId];
 
                 if (!existingReceipt) {
                     receipts[receiptType][userId] = {};
                 } else {
                     // we only want to add this receipt if we think it is later
                     // than the one we already have. (This is managed
                     // server-side, but because we synthesize RRs locally we
                     // have to do it here too.)
-                    var ordering = self.getUnfilteredTimelineSet().compareEventOrdering(
-                        existingReceipt.eventId, eventId);
+                    const ordering = self.getUnfilteredTimelineSet().compareEventOrdering(existingReceipt.eventId, eventId);
                     if (ordering !== null && ordering >= 0) {
                         return;
                     }
                 }
 
                 receipts[receiptType][userId] = {
                     eventId: eventId,
                     data: receipt
@@ -1043,242 +1595,236 @@ Room.prototype._addReceiptsToStructure =
     });
 };
 
 /**
  * Build and return a map of receipts by event ID
  * @param {Object} receipts A map of receipts
  * @return {Object} Map of receipts by event ID
  */
-Room.prototype._buildReceiptCache = function(receipts) {
-    var receiptCacheByEventId = {};
-    utils.keys(receipts).forEach(function(receiptType) {
-        utils.keys(receipts[receiptType]).forEach(function(userId) {
-            var receipt = receipts[receiptType][userId];
+Room.prototype._buildReceiptCache = function (receipts) {
+    const receiptCacheByEventId = {};
+    utils.keys(receipts).forEach(function (receiptType) {
+        utils.keys(receipts[receiptType]).forEach(function (userId) {
+            const receipt = receipts[receiptType][userId];
             if (!receiptCacheByEventId[receipt.eventId]) {
                 receiptCacheByEventId[receipt.eventId] = [];
             }
             receiptCacheByEventId[receipt.eventId].push({
                 userId: userId,
                 type: receiptType,
                 data: receipt.data
             });
         });
     });
     return receiptCacheByEventId;
 };
 
-
 /**
  * Add a temporary local-echo receipt to the room to reflect in the
  * client the fact that we've sent one.
  * @param {string} userId The user ID if the receipt sender
  * @param {MatrixEvent} e The event that is to be acknowledged
  * @param {string} receiptType The type of receipt
  */
-Room.prototype._addLocalEchoReceipt = function(userId, e, receiptType) {
+Room.prototype._addLocalEchoReceipt = function (userId, e, receiptType) {
     this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
 };
 
 /**
  * Update the room-tag event for the room.  The previous one is overwritten.
  * @param {MatrixEvent} event the m.tag event
  */
-Room.prototype.addTags = function(event) {
+Room.prototype.addTags = function (event) {
     // event content looks like:
     // content: {
     //    tags: {
     //       $tagName: { $metadata: $value },
     //       $tagName: { $metadata: $value },
     //    }
     // }
 
     // XXX: do we need to deep copy here?
-    this.tags = event.getContent().tags;
+    this.tags = event.getContent().tags || {};
 
     // XXX: we could do a deep-comparison to see if the tags have really
     // changed - but do we want to bother?
     this.emit("Room.tags", event, this);
 };
 
 /**
  * Update the account_data events for this room, overwriting events of the same type.
  * @param {Array<MatrixEvent>} events an array of account_data events to add
  */
-Room.prototype.addAccountData = function(events) {
-    for (var i = 0; i < events.length; i++) {
-        var event = events[i];
+Room.prototype.addAccountData = function (events) {
+    for (let i = 0; i < events.length; i++) {
+        const event = events[i];
         if (event.getType() === "m.tag") {
             this.addTags(event);
         }
         this.accountData[event.getType()] = event;
         this.emit("Room.accountData", event, this);
     }
 };
 
 /**
  * Access account_data event of given event type for this room
  * @param {string} type the type of account_data event to be accessed
  * @return {?MatrixEvent} the account_data event in question
  */
-Room.prototype.getAccountData = function(type) {
+Room.prototype.getAccountData = function (type) {
     return this.accountData[type];
 };
 
 /**
+ * Returns wheter the syncing user has permission to send a message in the room
+ * @return {boolean} true if the user should be permitted to send
+ *                   message events into the room.
+ */
+Room.prototype.maySendMessage = function () {
+    return this.getMyMembership() === 'join' && this.currentState.maySendEvent('m.room.message', this.myUserId);
+};
+
+/**
  * This is an internal method. Calculates the name of the room from the current
  * room state.
  * @param {Room} room The matrix room.
  * @param {string} userId The client's user ID. Used to filter room members
  * correctly.
  * @param {bool} ignoreRoomNameEvent Return the implicit room name that we'd see if there
  * was no m.room.name event.
  * @return {string} The calculated room name.
  */
 function calculateRoomName(room, userId, ignoreRoomNameEvent) {
     if (!ignoreRoomNameEvent) {
         // check for an alias, if any. for now, assume first alias is the
         // official one.
-        var mRoomName = room.currentState.getStateEvents("m.room.name", "");
+        const mRoomName = room.currentState.getStateEvents("m.room.name", "");
         if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) {
             return mRoomName.getContent().name;
         }
     }
 
-    var alias = room.getCanonicalAlias();
+    let alias = room.getCanonicalAlias();
 
     if (!alias) {
-        var aliases = room.getAliases();
+        const aliases = room.getAliases();
 
         if (aliases.length) {
             alias = aliases[0];
         }
     }
     if (alias) {
         return alias;
     }
 
-    // get members that are NOT ourselves and are actually in the room.
-    var otherMembers = utils.filter(room.currentState.getMembers(), function(m) {
-        return (m.userId !== userId && m.membership !== "leave");
-    });
-    var allMembers = utils.filter(room.currentState.getMembers(), function(m) {
-        return (m.membership !== "leave");
-    });
-    var myMemberEventArray = utils.filter(room.currentState.getMembers(), function(m) {
-        return (m.userId == userId);
-    });
-    var myMemberEvent = (
-        (myMemberEventArray.length && myMemberEventArray[0].events) ?
-            myMemberEventArray[0].events.member.event : undefined
-    );
+    const joinedMemberCount = room.currentState.getJoinedMemberCount();
+    const invitedMemberCount = room.currentState.getInvitedMemberCount();
+    // -1 because these numbers include the syncing user
+    const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1;
 
-    // TODO: Localisation
-    if (myMemberEvent && myMemberEvent.content.membership == "invite") {
-        if (room.currentState.getMember(myMemberEvent.sender)) {
-            // extract who invited us to the room
-            return "Invite from " + room.currentState.getMember(
-                myMemberEvent.sender
-            ).name;
-        } else if (allMembers[0].events.member) {
-            // use the sender field from the invite event, although this only
-            // gets us the mxid
-            return "Invite from " + myMemberEvent.sender;
-        } else {
-            return "Room Invite";
-        }
+    // get members that are NOT ourselves and are actually in the room.
+    let otherNames = null;
+    if (room._summaryHeroes) {
+        // if we have a summary, the member state events
+        // should be in the room state
+        otherNames = room._summaryHeroes.map(userId => {
+            const member = room.getMember(userId);
+            return member ? member.name : userId;
+        });
+    } else {
+        let otherMembers = room.currentState.getMembers().filter(m => {
+            return m.userId !== userId && (m.membership === "invite" || m.membership === "join");
+        });
+        // make sure members have stable order
+        otherMembers.sort((a, b) => a.userId.localeCompare(b.userId));
+        // only 5 first members, immitate _summaryHeroes
+        otherMembers = otherMembers.slice(0, 5);
+        otherNames = otherMembers.map(m => m.name);
     }
 
+    if (inviteJoinCount) {
+        return memberNamesToRoomName(otherNames, inviteJoinCount);
+    }
 
-    if (otherMembers.length === 0) {
-        if (allMembers.length === 1) {
-            // self-chat, peeked room with 1 participant,
-            // or inbound invite, or outbound 3PID invite.
-            if (allMembers[0].userId === userId) {
-                var thirdPartyInvites =
-                    room.currentState.getStateEvents("m.room.third_party_invite");
-                if (thirdPartyInvites && thirdPartyInvites.length > 0) {
-                    var name = "Inviting " +
-                               thirdPartyInvites[0].getContent().display_name;
-                    if (thirdPartyInvites.length > 1) {
-                        if (thirdPartyInvites.length == 2) {
-                            name += " and " +
-                                    thirdPartyInvites[1].getContent().display_name;
-                        }
-                        else {
-                            name += " and " +
-                                    thirdPartyInvites.length + " others";
-                        }
-                    }
-                    return name;
-                }
-                else {
-                    return "Empty room";
-                }
-            }
-            else {
-                return allMembers[0].name;
-            }
-        }
-        else {
-            // there really isn't anyone in this room...
-            return "Empty room";
+    const myMembership = room.getMyMembership();
+    // if I have created a room and invited people throuh
+    // 3rd party invites
+    if (myMembership == 'join') {
+        const thirdPartyInvites = room.currentState.getStateEvents("m.room.third_party_invite");
+
+        if (thirdPartyInvites && thirdPartyInvites.length) {
+            const thirdPartyNames = thirdPartyInvites.map(i => {
+                return i.getContent().display_name;
+            });
+
+            return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`;
         }
     }
-    else if (otherMembers.length === 1) {
-        return otherMembers[0].name;
+    // let's try to figure out who was here before
+    let leftNames = otherNames;
+    // if we didn't have heroes, try finding them in the room state
+    if (!leftNames.length) {
+        leftNames = room.currentState.getMembers().filter(m => {
+            return m.userId !== userId && m.membership !== "invite" && m.membership !== "join";
+        }).map(m => m.name);
     }
-    else if (otherMembers.length === 2) {
-        return (
-            otherMembers[0].name + " and " + otherMembers[1].name
-        );
-    }
-    else {
-        return (
-            otherMembers[0].name + " and " + (otherMembers.length - 1) + " others"
-        );
+    if (leftNames.length) {
+        return `Empty room (was ${memberNamesToRoomName(leftNames)})`;
+    } else {
+        return "Empty room";
     }
 }
 
-// FIXME: copypasted from sync.js
-function reEmit(reEmitEntity, emittableEntity, eventNames) {
-    utils.forEach(eventNames, function(eventName) {
-        // setup a listener on the entity (the Room, User, etc) for this event
-        emittableEntity.on(eventName, function() {
-            // take the args from the listener and reuse them, adding the
-            // event name to the arg list so it works with .emit()
-            // Transformation Example:
-            // listener on "foo" => function(a,b) { ... }
-            // Re-emit on "thing" => thing.emit("foo", a, b)
-            var newArgs = [eventName];
-            for (var i = 0; i < arguments.length; i++) {
-                newArgs.push(arguments[i]);
-            }
-            reEmitEntity.emit.apply(reEmitEntity, newArgs);
-        });
-    });
+function memberNamesToRoomName(names, count = names.length + 1) {
+    const countWithoutMe = count - 1;
+    if (!names.length) {
+        return "Empty room";
+    } else if (names.length === 1 && countWithoutMe <= 1) {
+        return names[0];
+    } else if (names.length === 2 && countWithoutMe <= 2) {
+        return `${names[0]} and ${names[1]}`;
+    } else {
+        const plural = countWithoutMe > 1;
+        if (plural) {
+            return `${names[0]} and ${countWithoutMe} others`;
+        } else {
+            return `${names[0]} and 1 other`;
+        }
+    }
 }
 
 /**
  * The Room class.
  */
 module.exports = Room;
 
 /**
  * Fires when an event we had previously received is redacted.
  *
  * (Note this is *not* fired when the redaction happens before we receive the
  * event).
  *
  * @event module:client~MatrixClient#"Room.redaction"
- * @param {MatrixEvent} event The matrix event which was redacted
+ * @param {MatrixEvent} event The matrix redaction event
  * @param {Room} room The room containing the redacted event
  */
 
 /**
+ * Fires when an event that was previously redacted isn't anymore.
+ * This happens when the redaction couldn't be sent and
+ * was subsequently cancelled by the user. Redactions have a local echo
+ * which is undone in this scenario.
+ *
+ * @event module:client~MatrixClient#"Room.redactionCancelled"
+ * @param {MatrixEvent} event The matrix redaction event that was cancelled.
+ * @param {Room} room The room containing the unredacted event
+ */
+
+/**
  * Fires whenever the name of a room is updated.
  * @event module:client~MatrixClient#"Room.name"
  * @param {Room} room The room whose Room.name was updated.
  * @example
  * matrixClient.on("Room.name", function(room){
  *   var newName = room.name;
  * });
  */
@@ -1347,9 +1893,9 @@ module.exports = Room;
  * @param {MatrixEvent} event The matrix event which has been updated
  *
  * @param {Room} room The room containing the redacted event
  *
  * @param {string} oldEventId The previous event id (the temporary event id,
  *    except when updating a successfully-sent event when its echo arrives)
  *
  * @param {EventStatus} oldStatus The previous event status.
- */
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/search-result.js
@@ -14,53 +14,52 @@ See the License for the specific languag
 limitations under the License.
 */
 "use strict";
 
 /**
  * @module models/search-result
  */
 
-var EventContext = require("./event-context");
-var utils = require("../utils");
+const EventContext = require("./event-context");
+const utils = require("../utils");
 
 /**
  * Construct a new SearchResult
  *
  * @param {number} rank   where this SearchResult ranks in the results
  * @param {event-context.EventContext} eventContext  the matching event and its
  *    context
  *
  * @constructor
  */
 function SearchResult(rank, eventContext) {
-    this.rank = rank;
-    this.context = eventContext;
+  this.rank = rank;
+  this.context = eventContext;
 }
 
 /**
  * Create a SearchResponse from the response to /search
  * @static
  * @param {Object} jsonObj
  * @param {function} eventMapper
  * @return {SearchResult}
  */
 
-SearchResult.fromJson = function(jsonObj, eventMapper) {
-    var jsonContext = jsonObj.context || {};
-    var events_before = jsonContext.events_before || [];
-    var events_after = jsonContext.events_after || [];
+SearchResult.fromJson = function (jsonObj, eventMapper) {
+  const jsonContext = jsonObj.context || {};
+  const events_before = jsonContext.events_before || [];
+  const events_after = jsonContext.events_after || [];
 
-    var context = new EventContext(eventMapper(jsonObj.result));
+  const context = new EventContext(eventMapper(jsonObj.result));
 
-    context.setPaginateToken(jsonContext.start, true);
-    context.addEvents(utils.map(events_before, eventMapper), true);
-    context.addEvents(utils.map(events_after, eventMapper), false);
-    context.setPaginateToken(jsonContext.end, false);
+  context.setPaginateToken(jsonContext.start, true);
+  context.addEvents(utils.map(events_before, eventMapper), true);
+  context.addEvents(utils.map(events_after, eventMapper), false);
+  context.setPaginateToken(jsonContext.end, false);
 
-    return new SearchResult(jsonObj.rank, context);
+  return new SearchResult(jsonObj.rank, context);
 };
 
-
 /**
  * The SearchResult class
  */
-module.exports = SearchResult;
+module.exports = SearchResult;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/models/user.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/models/user.js
@@ -12,18 +12,19 @@ distributed under the License is distrib
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * @module models/user
  */
- var EventEmitter = require("events").EventEmitter;
- var utils = require("../utils");
+
+const EventEmitter = require("events").EventEmitter;
+const utils = require("../utils");
 
 /**
  * Construct a new User. A User must have an ID and can optionally have extra
  * information associated with it.
  * @constructor
  * @param {string} userId Required. The ID of this user.
  * @prop {string} userId The ID of the user.
  * @prop {Object} info The info object supplied in the constructor.
@@ -34,23 +35,27 @@ limitations under the License.
  * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted
  *                proactively with the server, or we saw a message from the user
  * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last
  *                received presence data for this user.  We can subtract
  *                lastActiveAgo from this to approximate an absolute value for
  *                when a user was last active.
  * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
  *               an approximation and that the user should be seen as active 'now'
+ * @prop {string} _unstable_statusMessage The status message for the user, if known. This is
+ *                different from the presenceStatusMsg in that this is not tied to
+ *                the user's presence, and should be represented differently.
  * @prop {Object} events The events describing this user.
  * @prop {MatrixEvent} events.presence The m.presence event for this user.
  */
 function User(userId) {
     this.userId = userId;
     this.presence = "offline";
     this.presenceStatusMsg = null;
+    this._unstable_statusMessage = "";
     this.displayName = userId;
     this.rawDisplayName = userId;
     this.avatarUrl = null;
     this.lastActiveAgo = 0;
     this.lastPresenceTs = 0;
     this.currentlyActive = false;
     this.events = {
         presence: null,
@@ -64,130 +69,133 @@ utils.inherits(User, EventEmitter);
  * Update this User with the given presence event. May fire "User.presence",
  * "User.avatarUrl" and/or "User.displayName" if this event updates this user's
  * properties.
  * @param {MatrixEvent} event The <code>m.presence</code> event.
  * @fires module:client~MatrixClient#event:"User.presence"
  * @fires module:client~MatrixClient#event:"User.displayName"
  * @fires module:client~MatrixClient#event:"User.avatarUrl"
  */
-User.prototype.setPresenceEvent = function(event) {
+User.prototype.setPresenceEvent = function (event) {
     if (event.getType() !== "m.presence") {
         return;
     }
-    var firstFire = this.events.presence === null;
+    const firstFire = this.events.presence === null;
     this.events.presence = event;
 
-    var eventsToFire = [];
+    const eventsToFire = [];
     if (event.getContent().presence !== this.presence || firstFire) {
         eventsToFire.push("User.presence");
     }
-    if (event.getContent().avatar_url &&
-        event.getContent().avatar_url !== this.avatarUrl)
-    {
+    if (event.getContent().avatar_url && event.getContent().avatar_url !== this.avatarUrl) {
         eventsToFire.push("User.avatarUrl");
     }
-    if (event.getContent().displayname &&
-        event.getContent().displayname !== this.displayName)
-    {
+    if (event.getContent().displayname && event.getContent().displayname !== this.displayName) {
         eventsToFire.push("User.displayName");
     }
-    if (event.getContent().currently_active !== undefined &&
-        event.getContent().currently_active !== this.currentlyActive)
-    {
+    if (event.getContent().currently_active !== undefined && event.getContent().currently_active !== this.currentlyActive) {
         eventsToFire.push("User.currentlyActive");
     }
 
     this.presence = event.getContent().presence;
     eventsToFire.push("User.lastPresenceTs");
 
     if (event.getContent().status_msg) {
-      this.presenceStatusMsg = event.getContent().status_msg;
+        this.presenceStatusMsg = event.getContent().status_msg;
     }
     if (event.getContent().displayname) {
         this.displayName = event.getContent().displayname;
     }
     if (event.getContent().avatar_url) {
         this.avatarUrl = event.getContent().avatar_url;
     }
     this.lastActiveAgo = event.getContent().last_active_ago;
     this.lastPresenceTs = Date.now();
     this.currentlyActive = event.getContent().currently_active;
 
     this._updateModifiedTime();
 
-    for (var i = 0; i < eventsToFire.length; i++) {
+    for (let i = 0; i < eventsToFire.length; i++) {
         this.emit(eventsToFire[i], event, this);
     }
 };
 
 /**
  * Manually set this user's display name. No event is emitted in response to this
  * as there is no underlying MatrixEvent to emit with.
  * @param {string} name The new display name.
  */
-User.prototype.setDisplayName = function(name) {
-    var oldName = this.displayName;
+User.prototype.setDisplayName = function (name) {
+    const oldName = this.displayName;
     this.displayName = name;
     if (name !== oldName) {
         this._updateModifiedTime();
     }
 };
 
-
 /**
  * Manually set this user's non-disambiguated display name. No event is emitted
  * in response to this as there is no underlying MatrixEvent to emit with.
  * @param {string} name The new display name.
  */
-User.prototype.setRawDisplayName = function(name) {
+User.prototype.setRawDisplayName = function (name) {
     this.rawDisplayName = name;
 };
 
-
 /**
  * Manually set this user's avatar URL. No event is emitted in response to this
  * as there is no underlying MatrixEvent to emit with.
  * @param {string} url The new avatar URL.
  */
-User.prototype.setAvatarUrl = function(url) {
-    var oldUrl = this.avatarUrl;
+User.prototype.setAvatarUrl = function (url) {
+    const oldUrl = this.avatarUrl;
     this.avatarUrl = url;
     if (url !== oldUrl) {
         this._updateModifiedTime();
     }
 };
 
 /**
  * Update the last modified time to the current time.
  */
-User.prototype._updateModifiedTime = function() {
+User.prototype._updateModifiedTime = function () {
     this._modified = Date.now();
 };
 
 /**
  * Get the timestamp when this User was last updated. This timestamp is
  * updated when this User receives a new Presence event which has updated a
  * property on this object. It is updated <i>before</i> firing events.
  * @return {number} The timestamp
  */
-User.prototype.getLastModifiedTime = function() {
+User.prototype.getLastModifiedTime = function () {
     return this._modified;
 };
 
 /**
  * Get the absolute timestamp when this User was last known active on the server.
  * It is *NOT* accurate if this.currentlyActive is true.
  * @return {number} The timestamp
  */
-User.prototype.getLastActiveTs = function() {
+User.prototype.getLastActiveTs = function () {
     return this.lastPresenceTs - this.lastActiveAgo;
 };
 
 /**
+ * Manually set the user's status message.
+ * @param {MatrixEvent} event The <code>im.vector.user_status</code> event.
+ * @fires module:client~MatrixClient#event:"User._unstable_statusMessage"
+ */
+User.prototype._unstable_updateStatusMessage = function (event) {
+    if (!event.getContent()) this._unstable_statusMessage = "";else this._unstable_statusMessage = event.getContent()["status"];
+    this._updateModifiedTime();
+    this.emit("User._unstable_statusMessage", this);
+};
+
+/**
  * The User class.
  */
 module.exports = User;
 
 /**
  * Fires whenever any user's lastPresenceTs changes,
  * ie. whenever any presence event is received for a user.
  * @event module:client~MatrixClient#"User.lastPresenceTs"
@@ -236,9 +244,9 @@ module.exports = User;
  * Fires whenever any user's avatar URL changes.
  * @event module:client~MatrixClient#"User.avatarUrl"
  * @param {MatrixEvent} event The matrix event which caused this event to fire.
  * @param {User} user The user whose User.avatarUrl changed.
  * @example
  * matrixClient.on("User.avatarUrl", function(event, user){
  *   var newUrl = user.avatarUrl;
  * });
- */
+ */
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/pushprocessor.js
@@ -1,144 +1,215 @@
+'use strict';
+
+var _utils = require('./utils');
+
+/**
+ * @module pushprocessor
+ */
+
+const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride'];
+
+// The default override rules to apply when calculating actions for an event. These
+// defaults apply under no other circumstances to avoid confusing the client with server
+// state. We do this for two reasons:
+//   1. Synapse is unlikely to send us the push rule in an incremental sync - see
+//      https://github.com/matrix-org/synapse/pull/4867#issuecomment-481446072 for
+//      more details.
+//   2. We often want to start using push rules ahead of the server supporting them,
+//      and so we can put them here.
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
-/**
- * @module pushprocessor
- */
+
+const DEFAULT_OVERRIDE_RULES = [{
+    // For homeservers which don't support MSC1930 yet
+    rule_id: ".m.rule.tombstone",
+    default: true,
+    enabled: true,
+    conditions: [{
+        kind: "event_match",
+        key: "type",
+        pattern: "m.room.tombstone"
+    }, {
+        kind: "event_match",
+        key: "state_key",
+        pattern: ""
+    }],
+    actions: ["notify", {
+        set_tweak: "highlight",
+        value: true
+    }]
+}, {
+    // For homeservers which don't support MSC2153 yet
+    rule_id: ".m.rule.reaction",
+    default: true,
+    enabled: true,
+    conditions: [{
+        kind: "event_match",
+        key: "type",
+        pattern: "m.reaction"
+    }],
+    actions: ["dont_notify"]
+}];
 
 /**
  * Construct a Push Processor.
  * @constructor
  * @param {Object} client The Matrix client object to use
  */
 function PushProcessor(client) {
-    var escapeRegExp = function(string) {
-        return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+    const cachedGlobToRegex = {
+        // $glob: RegExp,
     };
 
-    var matchingRuleFromKindSet = function(ev, kindset, device) {
-        var rulekinds_in_order = ['override', 'content', 'room', 'sender', 'underride'];
-        for (var ruleKindIndex = 0;
-                ruleKindIndex < rulekinds_in_order.length;
-                ++ruleKindIndex) {
-            var kind = rulekinds_in_order[ruleKindIndex];
-            var ruleset = kindset[kind];
+    const matchingRuleFromKindSet = (ev, kindset, device) => {
+        for (let ruleKindIndex = 0; ruleKindIndex < RULEKINDS_IN_ORDER.length; ++ruleKindIndex) {
+            const kind = RULEKINDS_IN_ORDER[ruleKindIndex];
+            const ruleset = kindset[kind];
 
-            for (var ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) {
-                var rule = ruleset[ruleIndex];
-                if (!rule.enabled) { continue; }
+            for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) {
+                const rule = ruleset[ruleIndex];
+                if (!rule.enabled) {
+                    continue;
+                }
 
-                var rawrule = templateRuleToRaw(kind, rule, device);
-                if (!rawrule) { continue; }
+                const rawrule = templateRuleToRaw(kind, rule, device);
+                if (!rawrule) {
+                    continue;
+                }
 
-                if (ruleMatchesEvent(rawrule, ev)) {
+                if (this.ruleMatchesEvent(rawrule, ev)) {
                     rule.kind = kind;
                     return rule;
                 }
             }
         }
         return null;
     };
 
-    var templateRuleToRaw = function(kind, tprule, device) {
-        var rawrule = {
+    const templateRuleToRaw = function (kind, tprule, device) {
+        const rawrule = {
             'rule_id': tprule.rule_id,
             'actions': tprule.actions,
             'conditions': []
         };
         switch (kind) {
             case 'underride':
             case 'override':
                 rawrule.conditions = tprule.conditions;
                 break;
             case 'room':
-                if (!tprule.rule_id) { return null; }
+                if (!tprule.rule_id) {
+                    return null;
+                }
                 rawrule.conditions.push({
                     'kind': 'event_match',
                     'key': 'room_id',
-                    'pattern': tprule.rule_id
+                    'value': tprule.rule_id
                 });
                 break;
             case 'sender':
-                if (!tprule.rule_id) { return null; }
+                if (!tprule.rule_id) {
+                    return null;
+                }
                 rawrule.conditions.push({
                     'kind': 'event_match',
                     'key': 'user_id',
-                    'pattern': tprule.rule_id
+                    'value': tprule.rule_id
                 });
                 break;
             case 'content':
-                if (!tprule.pattern) { return null; }
+                if (!tprule.pattern) {
+                    return null;
+                }
                 rawrule.conditions.push({
                     'kind': 'event_match',
                     'key': 'content.body',
                     'pattern': tprule.pattern
                 });
                 break;
         }
         if (device) {
             rawrule.conditions.push({
                 'kind': 'device',
                 'profile_tag': device
             });
         }
         return rawrule;
     };
 
-    var ruleMatchesEvent = function(rule, ev) {
-        var ret = true;
-        for (var i = 0; i < rule.conditions.length; ++i) {
-            var cond = rule.conditions[i];
-            ret &= eventFulfillsCondition(cond, ev);
-        }
-        //console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match"));
-        return ret;
-    };
-
-    var eventFulfillsCondition = function(cond, ev) {
-        var condition_functions = {
+    const eventFulfillsCondition = function (cond, ev) {
+        const condition_functions = {
             "event_match": eventFulfillsEventMatchCondition,
             "device": eventFulfillsDeviceCondition,
             "contains_display_name": eventFulfillsDisplayNameCondition,
-            "room_member_count": eventFulfillsRoomMemberCountCondition
+            "room_member_count": eventFulfillsRoomMemberCountCondition,
+            "sender_notification_permission": eventFulfillsSenderNotifPermCondition
         };
         if (condition_functions[cond.kind]) {
             return condition_functions[cond.kind](cond, ev);
         }
-        return true;
+        // unknown conditions: we previously matched all unknown conditions,
+        // but given that rules can be added to the base rules on a server,
+        // it's probably better to not match unknown conditions.
+        return false;
     };
 
-    var eventFulfillsRoomMemberCountCondition = function(cond, ev) {
-        if (!cond.is) { return false; }
+    const eventFulfillsSenderNotifPermCondition = function (cond, ev) {
+        const notifLevelKey = cond['key'];
+        if (!notifLevelKey) {
+            return false;
+        }
 
-        var room = client.getRoom(ev.getRoomId());
-        if (!room || !room.currentState || !room.currentState.members) { return false; }
+        const room = client.getRoom(ev.getRoomId());
+        if (!room || !room.currentState) {
+            return false;
+        }
+
+        // Note that this should not be the current state of the room but the state at
+        // the point the event is in the DAG. Unfortunately the js-sdk does not store
+        // this.
+        return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender());
+    };
 
-        var memberCount = Object.keys(room.currentState.members).filter(function(m) {
-            return room.currentState.members[m].membership == 'join';
-        }).length;
+    const eventFulfillsRoomMemberCountCondition = function (cond, ev) {
+        if (!cond.is) {
+            return false;
+        }
+
+        const room = client.getRoom(ev.getRoomId());
+        if (!room || !room.currentState || !room.currentState.members) {
+            return false;
+        }
 
-        var m = cond.is.match(/^([=<>]*)([0-9]*)$/);
-        if (!m) { return false; }
-        var ineq = m[1];
-        var rhs = parseInt(m[2]);
-        if (isNaN(rhs)) { return false; }
+        const memberCount = room.currentState.getJoinedMemberCount();
+
+        const m = cond.is.match(/^([=<>]*)([0-9]*)$/);
+        if (!m) {
+            return false;
+        }
+        const ineq = m[1];
+        const rhs = parseInt(m[2]);
+        if (isNaN(rhs)) {
+            return false;
+        }
         switch (ineq) {
             case '':
             case '==':
                 return memberCount == rhs;
             case '<':
                 return memberCount < rhs;
             case '>':
                 return memberCount > rhs;
@@ -146,164 +217,272 @@ function PushProcessor(client) {
                 return memberCount <= rhs;
             case '>=':
                 return memberCount >= rhs;
             default:
                 return false;
         }
     };
 
-    var eventFulfillsDisplayNameCondition = function(cond, ev) {
-        var content = ev.getContent();
+    const eventFulfillsDisplayNameCondition = function (cond, ev) {
+        let content = ev.getContent();
+        if (ev.isEncrypted() && ev.getClearContent()) {
+            content = ev.getClearContent();
+        }
         if (!content || !content.body || typeof content.body != 'string') {
             return false;
         }
 
-        var room = client.getRoom(ev.getRoomId());
-        if (!room || !room.currentState || !room.currentState.members ||
-            !room.currentState.getMember(client.credentials.userId)) { return false; }
+        const room = client.getRoom(ev.getRoomId());
+        if (!room || !room.currentState || !room.currentState.members || !room.currentState.getMember(client.credentials.userId)) {
+            return false;
+        }
 
-        var displayName = room.currentState.getMember(client.credentials.userId).name;
+        const displayName = room.currentState.getMember(client.credentials.userId).name;
 
         // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay
         // as shorthand for [^0-9A-Za-z_].
-        var pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", 'i');
+        const pat = new RegExp("(^|\\W)" + (0, _utils.escapeRegExp)(displayName) + "(\\W|$)", 'i');
         return content.body.search(pat) > -1;
     };
 
-    var eventFulfillsDeviceCondition = function(cond, ev) {
+    const eventFulfillsDeviceCondition = function (cond, ev) {
         return false; // XXX: Allow a profile tag to be set for the web client instance
     };
 
-    var eventFulfillsEventMatchCondition = function(cond, ev) {
-        var val = valueForDottedKey(cond.key, ev);
-        if (!val || typeof val != 'string') { return false; }
+    const eventFulfillsEventMatchCondition = function (cond, ev) {
+        if (!cond.key) {
+            return false;
+        }
 
-        var pat;
+        const val = valueForDottedKey(cond.key, ev);
+        if (!val || typeof val != 'string') {
+            return false;
+        }
+
+        if (cond.value) {
+            return cond.value === val;
+        }
+
+        let regex;
+
         if (cond.key == 'content.body') {
-            pat = '(^|\\W)' + globToRegexp(cond.pattern) + '(\\W|$)';
+            regex = createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)');
         } else {
-            pat = '^' + globToRegexp(cond.pattern) + '$';
+            regex = createCachedRegex('^', cond.pattern, '$');
         }
-        var regex = new RegExp(pat, 'i');
+
         return !!val.match(regex);
     };
 
-    var globToRegexp = function(glob) {
-        // From
-        // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
-        // Because micromatch is about 130KB with dependencies,
-        // and minimatch is not much better.
-        var pat = escapeRegExp(glob);
-        pat = pat.replace(/\\\*/, '.*');
-        pat = pat.replace(/\?/, '.');
-        pat = pat.replace(/\\\[(!|)(.*)\\]/, function(match, p1, p2, offset, string) {
-            var first = p1 && '^' || '';
-            var second = p2.replace(/\\\-/, '-');
-            return '[' + first + second + ']';
-        });
-        return pat;
+    const createCachedRegex = function (prefix, glob, suffix) {
+        if (cachedGlobToRegex[glob]) {
+            return cachedGlobToRegex[glob];
+        }
+        cachedGlobToRegex[glob] = new RegExp(prefix + (0, _utils.globToRegexp)(glob) + suffix, 'i');
+        return cachedGlobToRegex[glob];
     };
 
-    var valueForDottedKey = function(key, ev) {
-        var parts = key.split('.');
-        var val;
+    const valueForDottedKey = function (key, ev) {
+        const parts = key.split('.');
+        let val;
 
         // special-case the first component to deal with encrypted messages
-        var firstPart = parts[0];
+        const firstPart = parts[0];
         if (firstPart == 'content') {
             val = ev.getContent();
             parts.shift();
         } else if (firstPart == 'type') {
             val = ev.getType();
             parts.shift();
         } else {
             // use the raw event for any other fields
             val = ev.event;
         }
 
         while (parts.length > 0) {
-            var thispart = parts.shift();
-            if (!val[thispart]) { return null; }
+            const thispart = parts.shift();
+            if (!val[thispart]) {
+                return null;
+            }
             val = val[thispart];
         }
         return val;
     };
 
-    var matchingRuleForEventWithRulesets = function(ev, rulesets) {
-        if (!rulesets || !rulesets.device) { return null; }
-        if (ev.getSender() == client.credentials.userId) { return null; }
+    const matchingRuleForEventWithRulesets = function (ev, rulesets) {
+        if (!rulesets || !rulesets.device) {
+            return null;
+        }
+        if (ev.getSender() == client.credentials.userId) {
+            return null;
+        }
 
-        var allDevNames = Object.keys(rulesets.device);
-        for (var i = 0; i < allDevNames.length; ++i) {
-            var devname = allDevNames[i];
-            var devrules = rulesets.device[devname];
+        const allDevNames = Object.keys(rulesets.device);
+        for (let i = 0; i < allDevNames.length; ++i) {
+            const devname = allDevNames[i];
+            const devrules = rulesets.device[devname];
 
-            var matchingRule = matchingRuleFromKindSet(devrules, devname);
-            if (matchingRule) { return matchingRule; }
+            const matchingRule = matchingRuleFromKindSet(devrules, devname);
+            if (matchingRule) {
+                return matchingRule;
+            }
         }
         return matchingRuleFromKindSet(ev, rulesets.global);
     };
 
-    var pushActionsForEventAndRulesets = function(ev, rulesets) {
-        var rule = matchingRuleForEventWithRulesets(ev, rulesets);
-        if (!rule) { return {}; }
+    const pushActionsForEventAndRulesets = function (ev, rulesets) {
+        const rule = matchingRuleForEventWithRulesets(ev, rulesets);
+        if (!rule) {
+            return {};
+        }
 
-        var actionObj = PushProcessor.actionListToActionsObject(rule.actions);
+        const actionObj = PushProcessor.actionListToActionsObject(rule.actions);
 
         // Some actions are implicit in some situations: we add those here
         if (actionObj.tweaks.highlight === undefined) {
             // if it isn't specified, highlight if it's a content
             // rule but otherwise not
-            actionObj.tweaks.highlight = (rule.kind == 'content');
+            actionObj.tweaks.highlight = rule.kind == 'content';
         }
 
         return actionObj;
     };
 
+    const applyRuleDefaults = function (clientRuleset) {
+        // Deep clone the object before we mutate it
+        const ruleset = JSON.parse(JSON.stringify(clientRuleset));
+
+        if (!clientRuleset['global']) {
+            clientRuleset['global'] = {};
+        }
+        if (!clientRuleset['global']['override']) {
+            clientRuleset['global']['override'] = [];
+        }
+
+        // Apply default overrides
+        const globalOverrides = clientRuleset['global']['override'];
+        for (const override of DEFAULT_OVERRIDE_RULES) {
+            const existingRule = globalOverrides.find(r => r.rule_id === override.rule_id);
+
+            if (!existingRule) {
+                const ruleId = override.rule_id;
+                console.warn(`Adding default global override for ${ruleId}`);
+                globalOverrides.push(override);
+            }
+        }
+
+        return ruleset;
+    };
+
+    this.ruleMatchesEvent = function (rule, ev) {
+        let ret = true;
+        for (let i = 0; i < rule.conditions.length; ++i) {
+            const cond = rule.conditions[i];
+            ret &= eventFulfillsCondition(cond, ev);
+        }
+        //console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match"));
+        return ret;
+    };
+
     /**
      * Get the user's push actions for the given event
      *
      * @param {module:models/event.MatrixEvent} ev
      *
      * @return {PushAction}
      */
-    this.actionsForEvent = function(ev) {
-        return pushActionsForEventAndRulesets(ev, client.pushRules);
+    this.actionsForEvent = function (ev) {
+        const rules = applyRuleDefaults(client.pushRules);
+        return pushActionsForEventAndRulesets(ev, rules);
+    };
+
+    /**
+     * Get one of the users push rules by its ID
+     *
+     * @param {string} ruleId The ID of the rule to search for
+     * @return {object} The push rule, or null if no such rule was found
+     */
+    this.getPushRuleById = function (ruleId) {
+        for (const scope of ['device', 'global']) {
+            if (client.pushRules[scope] === undefined) continue;
+
+            for (const kind of RULEKINDS_IN_ORDER) {
+                if (client.pushRules[scope][kind] === undefined) continue;
+
+                for (const rule of client.pushRules[scope][kind]) {
+                    if (rule.rule_id === ruleId) return rule;
+                }
+            }
+        }
+        return null;
     };
 }
 
 /**
  * Convert a list of actions into a object with the actions as keys and their values
  * eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ]
  *     becomes { notify: true, tweaks: { sound: 'default' } }
  * @param {array} actionlist The actions list
  *
  * @return {object} A object with key 'notify' (true or false) and an object of actions
  */
-PushProcessor.actionListToActionsObject = function(actionlist) {
-    var actionobj = { 'notify': false, 'tweaks': {} };
-    for (var i = 0; i < actionlist.length; ++i) {
-        var action = actionlist[i];
+PushProcessor.actionListToActionsObject = function (actionlist) {
+    const actionobj = { 'notify': false, 'tweaks': {} };
+    for (let i = 0; i < actionlist.length; ++i) {
+        const action = actionlist[i];
         if (action === 'notify') {
             actionobj.notify = true;
         } else if (typeof action === 'object') {
-            if (action.value === undefined) { action.value = true; }
+            if (action.value === undefined) {
+                action.value = true;
+            }
             actionobj.tweaks[action.set_tweak] = action.value;
         }
     }
     return actionobj;
 };
 
 /**
+ * Rewrites conditions on a client's push rules to match the defaults
+ * where applicable. Useful for upgrading push rules to more strict
+ * conditions when the server is falling behind on defaults.
+ * @param {object} incomingRules The client's existing push rules
+ * @returns {object} The rewritten rules
+ */
+PushProcessor.rewriteDefaultRules = function (incomingRules) {
+    let newRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone
+
+    // These lines are mostly to make the tests happy. We shouldn't run into these
+    // properties missing in practice.
+    if (!newRules) newRules = {};
+    if (!newRules.global) newRules.global = {};
+    if (!newRules.global.override) newRules.global.override = [];
+
+    // Fix default override rules
+    newRules.global.override = newRules.global.override.map(r => {
+        const defaultRule = DEFAULT_OVERRIDE_RULES.find(d => d.rule_id === r.rule_id);
+        if (!defaultRule) return r;
+
+        // Copy over the actions, default, and conditions. Don't touch the user's
+        // preference.
+        r.default = defaultRule.default;
+        r.conditions = defaultRule.conditions;
+        r.actions = defaultRule.actions;
+        return r;
+    });
+
+    return newRules;
+};
+
+/**
  * @typedef {Object} PushAction
  * @type {Object}
  * @property {boolean} notify Whether this event should notify the user or not.
  * @property {Object} tweaks How this event should be notified.
  * @property {boolean} tweaks.highlight Whether this event should be highlighted
  * on the UI.
  * @property {boolean} tweaks.sound Whether this notification should produce a
  * noise.
  */
 
 /** The PushProcessor class. */
-module.exports = PushProcessor;
-
+module.exports = PushProcessor;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/randomstring.js
@@ -0,0 +1,32 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+exports.randomString = randomString;
+/*
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+function randomString(len) {
+    let ret = "";
+    const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+    for (let i = 0; i < len; ++i) {
+        ret += chars.charAt(Math.floor(Math.random() * chars.length));
+    }
+
+    return ret;
+}
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/realtime-callbacks.js
@@ -20,102 +20,105 @@ limitations under the License.
  * suspending/resuming the system).
  *
  * In particular, if a timeout would have fired while the system was suspended,
  * it will instead fire as soon as possible after resume.
  */
 
 "use strict";
 
+var _logger = require("../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
 // we schedule a callback at least this often, to check if we've missed out on
 // some wall-clock time due to being suspended.
-var TIMER_CHECK_PERIOD_MS = 1000;
+const TIMER_CHECK_PERIOD_MS = 1000;
 
 // counter, for making up ids to return from setTimeout
-var _count = 0;
+let _count = 0;
 
 // the key for our callback with the real global.setTimeout
-var _realCallbackKey;
+let _realCallbackKey;
 
 // a sorted list of the callbacks to be run.
 // each is an object with keys [runAt, func, params, key].
-var _callbackList = [];
+const _callbackList = [];
 
-// var debuglog = console.log.bind(console);
-var debuglog = function() {};
+// var debuglog = logger.log.bind(logger);
+const debuglog = function () {};
 
 /**
  * Replace the function used by this module to get the current time.
  *
  * Intended for use by the unit tests.
  *
  * @param {function} f function which should return a millisecond counter
  *
  * @internal
  */
-module.exports.setNow = function(f) {
+module.exports.setNow = function (f) {
     _now = f || Date.now;
 };
-var _now = Date.now;
+let _now = Date.now;
 
 /**
  * reimplementation of window.setTimeout, which will call the callback if
  * the wallclock time goes past the deadline.
  *
  * @param {function} func   callback to be called after a delay
  * @param {Number} delayMs  number of milliseconds to delay by
  *
  * @return {Number} an identifier for this callback, which may be passed into
  *                   clearTimeout later.
  */
-module.exports.setTimeout = function(func, delayMs) {
+module.exports.setTimeout = function (func, delayMs) {
     delayMs = delayMs || 0;
     if (delayMs < 0) {
         delayMs = 0;
     }
 
-    var params = Array.prototype.slice.call(arguments, 2);
-    var runAt = _now() + delayMs;
-    var key = _count++;
-    debuglog("setTimeout: scheduling cb", key, "at", runAt,
-             "(delay", delayMs, ")");
-    var data = {
+    const params = Array.prototype.slice.call(arguments, 2);
+    const runAt = _now() + delayMs;
+    const key = _count++;
+    debuglog("setTimeout: scheduling cb", key, "at", runAt, "(delay", delayMs, ")");
+    const data = {
         runAt: runAt,
         func: func,
         params: params,
-        key: key,
+        key: key
     };
 
     // figure out where it goes in the list
-    var idx = binarySearch(
-        _callbackList, function(el) {
-            return el.runAt - runAt;
-        }
-    );
+    const idx = binarySearch(_callbackList, function (el) {
+        return el.runAt - runAt;
+    });
 
     _callbackList.splice(idx, 0, data);
     _scheduleRealCallback();
 
     return key;
 };
 
 /**
  * reimplementation of window.clearTimeout, which mirrors setTimeout
  *
  * @param {Number} key   result from an earlier setTimeout call
  */
-module.exports.clearTimeout = function(key) {
+module.exports.clearTimeout = function (key) {
     if (_callbackList.length === 0) {
         return;
     }
 
     // remove the element from the list
-    var i;
+    let i;
     for (i = 0; i < _callbackList.length; i++) {
-        var cb = _callbackList[i];
+        const cb = _callbackList[i];
         if (cb.key == key) {
             _callbackList.splice(i, 1);
             break;
         }
     }
 
     // iff it was the first one in the list, reschedule our callback.
     if (i === 0) {
@@ -124,80 +127,78 @@ module.exports.clearTimeout = function(k
 };
 
 // use the real global.setTimeout to schedule a callback to _runCallbacks.
 function _scheduleRealCallback() {
     if (_realCallbackKey) {
         global.clearTimeout(_realCallbackKey);
     }
 
-    var first = _callbackList[0];
+    const first = _callbackList[0];
 
     if (!first) {
         debuglog("_scheduleRealCallback: no more callbacks, not rescheduling");
         return;
     }
 
-    var now = _now();
-    var delayMs = Math.min(first.runAt - now, TIMER_CHECK_PERIOD_MS);
+    const now = _now();
+    const delayMs = Math.min(first.runAt - now, TIMER_CHECK_PERIOD_MS);
 
     debuglog("_scheduleRealCallback: now:", now, "delay:", delayMs);
     _realCallbackKey = global.setTimeout(_runCallbacks, delayMs);
 }
 
 function _runCallbacks() {
-    var cb;
-    var now = _now();
+    let cb;
+    const now = _now();
     debuglog("_runCallbacks: now:", now);
 
     // get the list of things to call
-    var callbacksToRun = [];
+    const callbacksToRun = [];
     while (true) {
-        var first = _callbackList[0];
+        const first = _callbackList[0];
         if (!first || first.runAt > now) {
             break;
         }
         cb = _callbackList.shift();
         debuglog("_runCallbacks: popping", cb.key);
         callbacksToRun.push(cb);
     }
 
     // reschedule the real callback before running our functions, to
     // keep the codepaths the same whether or not our functions
     // register their own setTimeouts.
     _scheduleRealCallback();
 
-    for (var i = 0; i < callbacksToRun.length; i++) {
+    for (let i = 0; i < callbacksToRun.length; i++) {
         cb = callbacksToRun[i];
         try {
-            cb.func.apply(null, cb.params);
+            cb.func.apply(global, cb.params);
         } catch (e) {
-            console.error("Uncaught exception in callback function",
-                          e.stack || e);
+            _logger2.default.error("Uncaught exception in callback function", e.stack || e);
         }
     }
 }
 
-
 /* search in a sorted array.
  *
  * returns the index of the last element for which func returns
  * greater than zero, or array.length if no such element exists.
  */
 function binarySearch(array, func) {
     // min is inclusive, max exclusive.
-    var min = 0,
+    let min = 0,
         max = array.length;
 
     while (min < max) {
-        var mid = (min + max) >> 1;
-        var res = func(array[mid]);
+        const mid = min + max >> 1;
+        const res = func(array[mid]);
         if (res > 0) {
             // the element at 'mid' is too big; set it as the new max.
             max = mid;
         } else {
             // the element at 'mid' is too small. 'min' is inclusive, so +1.
             min = mid + 1;
         }
     }
     // presumably, min==max now.
     return min;
-}
+}
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/scheduler.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/scheduler.js
@@ -14,20 +14,31 @@ See the License for the specific languag
 limitations under the License.
 */
 "use strict";
 /**
  * This is an internal module which manages queuing, scheduling and retrying
  * of requests.
  * @module scheduler
  */
-var utils = require("./utils");
-var q = require("q");
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require("../src/logger");
 
-var DEBUG = false;  // set true to enable console logging.
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const utils = require("./utils");
+
+
+const DEBUG = false; // set true to enable console logging.
 
 /**
  * Construct a scheduler for Matrix. Requires
  * {@link module:scheduler~MatrixScheduler#setProcessFunction} to be provided
  * with a way of processing events.
  * @constructor
  * @param {module:scheduler~retryAlgorithm} retryAlgorithm Optional. The retry
  * algorithm to apply when determining when to try to send an event again.
@@ -54,226 +65,223 @@ function MatrixScheduler(retryAlgorithm,
  * Retrieve a queue based on an event. The event provided does not need to be in
  * the queue.
  * @param {MatrixEvent} event An event to get the queue for.
  * @return {?Array<MatrixEvent>} A shallow copy of events in the queue or null.
  * Modifying this array will not modify the list itself. Modifying events in
  * this array <i>will</i> modify the underlying event in the queue.
  * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue.
  */
-MatrixScheduler.prototype.getQueueForEvent = function(event) {
-    var name = this.queueAlgorithm(event);
+MatrixScheduler.prototype.getQueueForEvent = function (event) {
+    const name = this.queueAlgorithm(event);
     if (!name || !this._queues[name]) {
         return null;
     }
-    return utils.map(this._queues[name], function(obj) {
+    return utils.map(this._queues[name], function (obj) {
         return obj.event;
     });
 };
 
 /**
  * Remove this event from the queue. The event is equal to another event if they
  * have the same ID returned from event.getId().
  * @param {MatrixEvent} event The event to remove.
  * @return {boolean} True if this event was removed.
  */
-MatrixScheduler.prototype.removeEventFromQueue = function(event) {
-    var name = this.queueAlgorithm(event);
+MatrixScheduler.prototype.removeEventFromQueue = function (event) {
+    const name = this.queueAlgorithm(event);
     if (!name || !this._queues[name]) {
         return false;
     }
-    var removed = false;
-    utils.removeElement(this._queues[name], function(element) {
+    let removed = false;
+    utils.removeElement(this._queues[name], function (element) {
         if (element.event.getId() === event.getId()) {
+            // XXX we should probably reject the promise?
+            // https://github.com/matrix-org/matrix-js-sdk/issues/496
             removed = true;
             return true;
         }
     });
     return removed;
 };
 
-
 /**
  * Set the process function. Required for events in the queue to be processed.
  * If set after events have been added to the queue, this will immediately start
  * processing them.
  * @param {module:scheduler~processFn} fn The function that can process events
  * in the queue.
  */
-MatrixScheduler.prototype.setProcessFunction = function(fn) {
+MatrixScheduler.prototype.setProcessFunction = function (fn) {
     this._procFn = fn;
     _startProcessingQueues(this);
 };
 
 /**
  * Queue an event if it is required and start processing queues.
  * @param {MatrixEvent} event The event that may be queued.
  * @return {?Promise} A promise if the event was queued, which will be
  * resolved or rejected in due time, else null.
  */
-MatrixScheduler.prototype.queueEvent = function(event) {
-    var queueName = this.queueAlgorithm(event);
+MatrixScheduler.prototype.queueEvent = function (event) {
+    const queueName = this.queueAlgorithm(event);
     if (!queueName) {
         return null;
     }
     // add the event to the queue and make a deferred for it.
     if (!this._queues[queueName]) {
         this._queues[queueName] = [];
     }
-    var defer = q.defer();
+    const defer = _bluebird2.default.defer();
     this._queues[queueName].push({
         event: event,
         defer: defer,
         attempts: 0
     });
-    debuglog(
-        "Queue algorithm dumped event %s into queue '%s'",
-        event.getId(), queueName
-    );
+    debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
     _startProcessingQueues(this);
     return defer.promise;
 };
 
 /**
  * Retries events up to 4 times using exponential backoff. This produces wait
  * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the
  * failure was due to a rate limited request, the time specified in the error is
  * waited before being retried.
  * @param {MatrixEvent} event
  * @param {Number} attempts
  * @param {MatrixError} err
  * @return {Number}
  * @see module:scheduler~retryAlgorithm
  */
-MatrixScheduler.RETRY_BACKOFF_RATELIMIT = function(event, attempts, err) {
+MatrixScheduler.RETRY_BACKOFF_RATELIMIT = function (event, attempts, err) {
     if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) {
         // client error; no amount of retrying with save you now.
         return -1;
     }
     // we ship with browser-request which returns { cors: rejected } when trying
     // with no connection, so if we match that, give up since they have no conn.
     if (err.cors === "rejected") {
         return -1;
     }
 
     if (err.name === "M_LIMIT_EXCEEDED") {
-        var waitTime = err.data.retry_after_ms;
+        const waitTime = err.data.retry_after_ms;
         if (waitTime) {
             return waitTime;
         }
     }
     if (attempts > 4) {
         return -1; // give up
     }
-    return (1000 * Math.pow(2, attempts));
+    return 1000 * Math.pow(2, attempts);
 };
 
 /**
  * Queues <code>m.room.message</code> events and lets other events continue
  * concurrently.
  * @param {MatrixEvent} event
  * @return {string}
  * @see module:scheduler~queueAlgorithm
  */
-MatrixScheduler.QUEUE_MESSAGES = function(event) {
-    if (event.getType() === "m.room.message") {
+MatrixScheduler.QUEUE_MESSAGES = function (event) {
+    // enqueue messages or events that associate with another event (redactions and relations)
+    if (event.getType() === "m.room.message" || event.hasAssocation()) {
         // put these events in the 'message' queue.
         return "message";
     }
     // allow all other events continue concurrently.
     return null;
 };
 
 function _startProcessingQueues(scheduler) {
     if (!scheduler._procFn) {
         return;
     }
     // for each inactive queue with events in them
-    utils.forEach(utils.filter(utils.keys(scheduler._queues), function(queueName) {
-        return scheduler._activeQueues.indexOf(queueName) === -1 &&
-                scheduler._queues[queueName].length > 0;
-    }), function(queueName) {
+    utils.forEach(utils.filter(utils.keys(scheduler._queues), function (queueName) {
+        return scheduler._activeQueues.indexOf(queueName) === -1 && scheduler._queues[queueName].length > 0;
+    }), function (queueName) {
         // mark the queue as active
         scheduler._activeQueues.push(queueName);
         // begin processing the head of the queue
         debuglog("Spinning up queue: '%s'", queueName);
         _processQueue(scheduler, queueName);
     });
 }
 
 function _processQueue(scheduler, queueName) {
     // get head of queue
-    var obj = _peekNextEvent(scheduler, queueName);
+    const obj = _peekNextEvent(scheduler, queueName);
     if (!obj) {
         // queue is empty. Mark as inactive and stop recursing.
-        var index = scheduler._activeQueues.indexOf(queueName);
+        const index = scheduler._activeQueues.indexOf(queueName);
         if (index >= 0) {
             scheduler._activeQueues.splice(index, 1);
         }
         debuglog("Stopping queue '%s' as it is now empty", queueName);
         return;
     }
-    debuglog(
-        "Queue '%s' has %s pending events",
-        queueName, scheduler._queues[queueName].length
-    );
+    debuglog("Queue '%s' has %s pending events", queueName, scheduler._queues[queueName].length);
     // fire the process function and if it resolves, resolve the deferred. Else
     // invoke the retry algorithm.
-    scheduler._procFn(obj.event).done(function(res) {
+
+    // First wait for a resolved promise, so the resolve handlers for
+    // the deferred of the previously sent event can run.
+    // This way enqueued relations/redactions to enqueued events can receive
+    // the remove id of their target before being sent.
+    _bluebird2.default.resolve().then(() => {
+        return scheduler._procFn(obj.event);
+    }).then(function (res) {
         // remove this from the queue
         _removeNextEvent(scheduler, queueName);
         debuglog("Queue '%s' sent event %s", queueName, obj.event.getId());
         obj.defer.resolve(res);
         // keep processing
         _processQueue(scheduler, queueName);
-    }, function(err) {
+    }, function (err) {
         obj.attempts += 1;
         // ask the retry algorithm when/if we should try again
-        var waitTimeMs = scheduler.retryAlgorithm(obj.event, obj.attempts, err);
-        debuglog(
-            "retry(%s) err=%s event_id=%s waitTime=%s",
-            obj.attempts, err, obj.event.getId(), waitTimeMs
-        );
-        if (waitTimeMs === -1) {  // give up (you quitter!)
-            debuglog(
-                "Queue '%s' giving up on event %s", queueName, obj.event.getId()
-            );
+        const waitTimeMs = scheduler.retryAlgorithm(obj.event, obj.attempts, err);
+        debuglog("retry(%s) err=%s event_id=%s waitTime=%s", obj.attempts, err, obj.event.getId(), waitTimeMs);
+        if (waitTimeMs === -1) {
+            // give up (you quitter!)
+            debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId());
             // remove this from the queue
             _removeNextEvent(scheduler, queueName);
             obj.defer.reject(err);
             // process next event
             _processQueue(scheduler, queueName);
-        }
-        else {
-            setTimeout(function() {
+        } else {
+            setTimeout(function () {
                 _processQueue(scheduler, queueName);
             }, waitTimeMs);
         }
     });
 }
 
 function _peekNextEvent(scheduler, queueName) {
-    var queue = scheduler._queues[queueName];
+    const queue = scheduler._queues[queueName];
     if (!utils.isArray(queue)) {
         return null;
     }
     return queue[0];
 }
 
 function _removeNextEvent(scheduler, queueName) {
-    var queue = scheduler._queues[queueName];
+    const queue = scheduler._queues[queueName];
     if (!utils.isArray(queue)) {
         return null;
     }
     return queue.shift();
 }
 
 function debuglog() {
     if (DEBUG) {
-        console.log.apply(console, arguments);
+        _logger2.default.log(...arguments);
     }
 }
 
 /**
  * The retry algorithm to apply when retrying events. To stop retrying, return
  * <code>-1</code>. If this event was part of a queue, it will be removed from
  * the queue.
  * @callback retryAlgorithm
@@ -297,19 +305,19 @@ function debuglog() {
  * queue will be sent.
  * @callback queueAlgorithm
  * @param {MatrixEvent} event The event to be sent.
  * @return {string} The name of the queue to put the event into. If a queue with
  * this name does not exist, it will be created. If this is <code>null</code>,
  * the event is not put into a queue and will be sent concurrently.
  */
 
- /**
- * The function to invoke to process (send) events in the queue.
- * @callback processFn
- * @param {MatrixEvent} event The event to send.
- * @return {Promise} Resolved/rejected depending on the outcome of the request.
- */
+/**
+* The function to invoke to process (send) events in the queue.
+* @callback processFn
+* @param {MatrixEvent} event The event to send.
+* @return {Promise} Resolved/rejected depending on the outcome of the request.
+*/
 
 /**
  * The MatrixScheduler class.
  */
-module.exports = MatrixScheduler;
+module.exports = MatrixScheduler;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/service-types.js
@@ -0,0 +1,25 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+const SERVICE_TYPES = exports.SERVICE_TYPES = Object.freeze({
+    IS: 'SERVICE_TYPE_IS', // An Identity Service
+    IM: 'SERVICE_TYPE_IM' // An Integration Manager
+});
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-local-backend.js
@@ -0,0 +1,543 @@
+"use strict";
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _syncAccumulator = require("../sync-accumulator");
+
+var _syncAccumulator2 = _interopRequireDefault(_syncAccumulator);
+
+var _utils = require("../utils");
+
+var _utils2 = _interopRequireDefault(_utils);
+
+var _indexeddbHelpers = require("../indexeddb-helpers");
+
+var IndexedDBHelpers = _interopRequireWildcard(_indexeddbHelpers);
+
+var _logger = require("../../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const VERSION = 3; /*
+                   Copyright 2017 Vector Creations Ltd
+                   Copyright 2018 New Vector Ltd
+                   
+                   Licensed under the Apache License, Version 2.0 (the "License");
+                   you may not use this file except in compliance with the License.
+                   You may obtain a copy of the License at
+                   
+                       http://www.apache.org/licenses/LICENSE-2.0
+                   
+                   Unless required by applicable law or agreed to in writing, software
+                   distributed under the License is distributed on an "AS IS" BASIS,
+                   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+                   See the License for the specific language governing permissions and
+                   limitations under the License.
+                   */
+
+function createDatabase(db) {
+    // Make user store, clobber based on user ID. (userId property of User objects)
+    db.createObjectStore("users", { keyPath: ["userId"] });
+
+    // Make account data store, clobber based on event type.
+    // (event.type property of MatrixEvent objects)
+    db.createObjectStore("accountData", { keyPath: ["type"] });
+
+    // Make /sync store (sync tokens, room data, etc), always clobber (const key).
+    db.createObjectStore("sync", { keyPath: ["clobber"] });
+}
+
+function upgradeSchemaV2(db) {
+    const oobMembersStore = db.createObjectStore("oob_membership_events", {
+        keyPath: ["room_id", "state_key"]
+    });
+    oobMembersStore.createIndex("room", "room_id");
+}
+
+function upgradeSchemaV3(db) {
+    db.createObjectStore("client_options", { keyPath: ["clobber"] });
+}
+
+/**
+ * Helper method to collect results from a Cursor and promiseify it.
+ * @param {ObjectStore|Index} store The store to perform openCursor on.
+ * @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor.
+ * @param {Function} resultMapper A function which is repeatedly called with a
+ * Cursor.
+ * Return the data you want to keep.
+ * @return {Promise<T[]>} Resolves to an array of whatever you returned from
+ * resultMapper.
+ */
+function selectQuery(store, keyRange, resultMapper) {
+    const query = store.openCursor(keyRange);
+    return new _bluebird2.default((resolve, reject) => {
+        const results = [];
+        query.onerror = event => {
+            reject(new Error("Query failed: " + event.target.errorCode));
+        };
+        // collect results
+        query.onsuccess = event => {
+            const cursor = event.target.result;
+            if (!cursor) {
+                resolve(results);
+                return; // end of results
+            }
+            results.push(resultMapper(cursor));
+            cursor.continue();
+        };
+    });
+}
+
+function txnAsPromise(txn) {
+    return new _bluebird2.default((resolve, reject) => {
+        txn.oncomplete = function (event) {
+            resolve(event);
+        };
+        txn.onerror = function (event) {
+            reject(event.target.error);
+        };
+    });
+}
+
+function reqAsEventPromise(req) {
+    return new _bluebird2.default((resolve, reject) => {
+        req.onsuccess = function (event) {
+            resolve(event);
+        };
+        req.onerror = function (event) {
+            reject(event.target.error);
+        };
+    });
+}
+
+function reqAsPromise(req) {
+    return new _bluebird2.default((resolve, reject) => {
+        req.onsuccess = () => resolve(req);
+        req.onerror = err => reject(err);
+    });
+}
+
+function reqAsCursorPromise(req) {
+    return reqAsEventPromise(req).then(event => event.target.result);
+}
+
+/**
+ * Does the actual reading from and writing to the indexeddb
+ *
+ * Construct a new Indexed Database store backend. This requires a call to
+ * <code>connect()</code> before this store can be used.
+ * @constructor
+ * @param {Object} indexedDBInterface The Indexed DB interface e.g
+ * <code>window.indexedDB</code>
+ * @param {string=} dbName Optional database name. The same name must be used
+ * to open the same database.
+ */
+const LocalIndexedDBStoreBackend = function LocalIndexedDBStoreBackend(indexedDBInterface, dbName) {
+    this.indexedDB = indexedDBInterface;
+    this._dbName = "matrix-js-sdk:" + (dbName || "default");
+    this.db = null;
+    this._disconnected = true;
+    this._syncAccumulator = new _syncAccumulator2.default();
+    this._isNewlyCreated = false;
+};
+
+LocalIndexedDBStoreBackend.exists = function (indexedDB, dbName) {
+    dbName = "matrix-js-sdk:" + (dbName || "default");
+    return IndexedDBHelpers.exists(indexedDB, dbName);
+};
+
+LocalIndexedDBStoreBackend.prototype = {
+    /**
+     * Attempt to connect to the database. This can fail if the user does not
+     * grant permission.
+     * @return {Promise} Resolves if successfully connected.
+     */
+    connect: function () {
+        if (!this._disconnected) {
+            _logger2.default.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`);
+            return _bluebird2.default.resolve();
+        }
+
+        this._disconnected = false;
+
+        _logger2.default.log(`LocalIndexedDBStoreBackend.connect: connecting...`);
+        const req = this.indexedDB.open(this._dbName, VERSION);
+        req.onupgradeneeded = ev => {
+            const db = ev.target.result;
+            const oldVersion = ev.oldVersion;
+            _logger2.default.log(`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`);
+            if (oldVersion < 1) {
+                // The database did not previously exist.
+                this._isNewlyCreated = true;
+                createDatabase(db);
+            }
+            if (oldVersion < 2) {
+                upgradeSchemaV2(db);
+            }
+            if (oldVersion < 3) {
+                upgradeSchemaV3(db);
+            }
+            // Expand as needed.
+        };
+
+        req.onblocked = () => {
+            _logger2.default.log(`can't yet open LocalIndexedDBStoreBackend because it is open elsewhere`);
+        };
+
+        _logger2.default.log(`LocalIndexedDBStoreBackend.connect: awaiting connection...`);
+        return reqAsEventPromise(req).then(ev => {
+            _logger2.default.log(`LocalIndexedDBStoreBackend.connect: connected`);
+            this.db = ev.target.result;
+
+            // add a poorly-named listener for when deleteDatabase is called
+            // so we can close our db connections.
+            this.db.onversionchange = () => {
+                this.db.close();
+            };
+
+            return this._init();
+        });
+    },
+    /** @return {bool} whether or not the database was newly created in this session. */
+    isNewlyCreated: function () {
+        return _bluebird2.default.resolve(this._isNewlyCreated);
+    },
+
+    /**
+     * Having connected, load initial data from the database and prepare for use
+     * @return {Promise} Resolves on success
+     */
+    _init: function () {
+        return _bluebird2.default.all([this._loadAccountData(), this._loadSyncData()]).then(([accountData, syncData]) => {
+            _logger2.default.log(`LocalIndexedDBStoreBackend: loaded initial data`);
+            this._syncAccumulator.accumulate({
+                next_batch: syncData.nextBatch,
+                rooms: syncData.roomsData,
+                groups: syncData.groupsData,
+                account_data: {
+                    events: accountData
+                }
+            });
+        });
+    },
+
+    /**
+     * Returns the out-of-band membership events for this room that
+     * were previously loaded.
+     * @param {string} roomId
+     * @returns {Promise<event[]>} the events, potentially an empty array if OOB loading didn't yield any new members
+     * @returns {null} in case the members for this room haven't been stored yet
+     */
+    getOutOfBandMembers: function (roomId) {
+        return new _bluebird2.default((resolve, reject) => {
+            const tx = this.db.transaction(["oob_membership_events"], "readonly");
+            const store = tx.objectStore("oob_membership_events");
+            const roomIndex = store.index("room");
+            const range = IDBKeyRange.only(roomId);
+            const request = roomIndex.openCursor(range);
+
+            const membershipEvents = [];
+            // did we encounter the oob_written marker object
+            // amongst the results? That means OOB member
+            // loading already happened for this room
+            // but there were no members to persist as they
+            // were all known already
+            let oobWritten = false;
+
+            request.onsuccess = event => {
+                const cursor = event.target.result;
+                if (!cursor) {
+                    // Unknown room
+                    if (!membershipEvents.length && !oobWritten) {
+                        return resolve(null);
+                    }
+                    return resolve(membershipEvents);
+                }
+                const record = cursor.value;
+                if (record.oob_written) {
+                    oobWritten = true;
+                } else {
+                    membershipEvents.push(record);
+                }
+                cursor.continue();
+            };
+            request.onerror = err => {
+                reject(err);
+            };
+        }).then(events => {
+            _logger2.default.log(`LL: got ${events && events.length}` + ` membershipEvents from storage for room ${roomId} ...`);
+            return events;
+        });
+    },
+
+    /**
+     * Stores the out-of-band membership events for this room. Note that
+     * it still makes sense to store an empty array as the OOB status for the room is
+     * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+     * @param {string} roomId
+     * @param {event[]} membershipEvents the membership events to store
+     */
+    setOutOfBandMembers: async function (roomId, membershipEvents) {
+        _logger2.default.log(`LL: backend about to store ${membershipEvents.length}` + ` members for ${roomId}`);
+        const tx = this.db.transaction(["oob_membership_events"], "readwrite");
+        const store = tx.objectStore("oob_membership_events");
+        membershipEvents.forEach(e => {
+            store.put(e);
+        });
+        // aside from all the events, we also write a marker object to the store
+        // to mark the fact that OOB members have been written for this room.
+        // It's possible that 0 members need to be written as all where previously know
+        // but we still need to know whether to return null or [] from getOutOfBandMembers
+        // where null means out of band members haven't been stored yet for this room
+        const markerObject = {
+            room_id: roomId,
+            oob_written: true,
+            state_key: 0
+        };
+        store.put(markerObject);
+        await txnAsPromise(tx);
+        _logger2.default.log(`LL: backend done storing for ${roomId}!`);
+    },
+
+    clearOutOfBandMembers: async function (roomId) {
+        // the approach to delete all members for a room
+        // is to get the min and max state key from the index
+        // for that room, and then delete between those
+        // keys in the store.
+        // this should be way faster than deleting every member
+        // individually for a large room.
+        const readTx = this.db.transaction(["oob_membership_events"], "readonly");
+        const store = readTx.objectStore("oob_membership_events");
+        const roomIndex = store.index("room");
+        const roomRange = IDBKeyRange.only(roomId);
+
+        const minStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "next")).then(cursor => cursor && cursor.primaryKey[1]);
+        const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then(cursor => cursor && cursor.primaryKey[1]);
+        const [minStateKey, maxStateKey] = await _bluebird2.default.all([minStateKeyProm, maxStateKeyProm]);
+
+        const writeTx = this.db.transaction(["oob_membership_events"], "readwrite");
+        const writeStore = writeTx.objectStore("oob_membership_events");
+        const membersKeyRange = IDBKeyRange.bound([roomId, minStateKey], [roomId, maxStateKey]);
+
+        _logger2.default.log(`LL: Deleting all users + marker in storage for ` + `room ${roomId}, with key range:`, [roomId, minStateKey], [roomId, maxStateKey]);
+        await reqAsPromise(writeStore.delete(membersKeyRange));
+    },
+
+    /**
+     * Clear the entire database. This should be used when logging out of a client
+     * to prevent mixing data between accounts.
+     * @return {Promise} Resolved when the database is cleared.
+     */
+    clearDatabase: function () {
+        return new _bluebird2.default((resolve, reject) => {
+            _logger2.default.log(`Removing indexeddb instance: ${this._dbName}`);
+            const req = this.indexedDB.deleteDatabase(this._dbName);
+
+            req.onblocked = () => {
+                _logger2.default.log(`can't yet delete indexeddb ${this._dbName}` + ` because it is open elsewhere`);
+            };
+
+            req.onerror = ev => {
+                // in firefox, with indexedDB disabled, this fails with a
+                // DOMError. We treat this as non-fatal, so that we can still
+                // use the app.
+                _logger2.default.warn(`unable to delete js-sdk store indexeddb: ${ev.target.error}`);
+                resolve();
+            };
+
+            req.onsuccess = () => {
+                _logger2.default.log(`Removed indexeddb instance: ${this._dbName}`);
+                resolve();
+            };
+        });
+    },
+
+    /**
+     * @param {boolean=} copy If false, the data returned is from internal
+     * buffers and must not be mutated. Otherwise, a copy is made before
+     * returning such that the data can be safely mutated. Default: true.
+     *
+     * @return {Promise} Resolves with a sync response to restore the
+     * client state to where it was at the last save, or null if there
+     * is no saved sync data.
+     */
+    getSavedSync: function (copy) {
+        if (copy === undefined) copy = true;
+
+        const data = this._syncAccumulator.getJSON();
+        if (!data.nextBatch) return _bluebird2.default.resolve(null);
+        if (copy) {
+            // We must deep copy the stored data so that the /sync processing code doesn't
+            // corrupt the internal state of the sync accumulator (it adds non-clonable keys)
+            return _bluebird2.default.resolve(_utils2.default.deepCopy(data));
+        } else {
+            return _bluebird2.default.resolve(data);
+        }
+    },
+
+    getNextBatchToken: function () {
+        return _bluebird2.default.resolve(this._syncAccumulator.getNextBatchToken());
+    },
+
+    setSyncData: function (syncData) {
+        return _bluebird2.default.resolve().then(() => {
+            this._syncAccumulator.accumulate(syncData);
+        });
+    },
+
+    syncToDatabase: function (userTuples) {
+        const syncData = this._syncAccumulator.getJSON();
+
+        return _bluebird2.default.all([this._persistUserPresenceEvents(userTuples), this._persistAccountData(syncData.accountData), this._persistSyncData(syncData.nextBatch, syncData.roomsData, syncData.groupsData)]);
+    },
+
+    /**
+     * Persist rooms /sync data along with the next batch token.
+     * @param {string} nextBatch The next_batch /sync value.
+     * @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator
+     * @param {Object} groupsData The 'groups' /sync data from a SyncAccumulator
+     * @return {Promise} Resolves if the data was persisted.
+     */
+    _persistSyncData: function (nextBatch, roomsData, groupsData) {
+        _logger2.default.log("Persisting sync data up to ", nextBatch);
+        return _bluebird2.default.try(() => {
+            const txn = this.db.transaction(["sync"], "readwrite");
+            const store = txn.objectStore("sync");
+            store.put({
+                clobber: "-", // constant key so will always clobber
+                nextBatch: nextBatch,
+                roomsData: roomsData,
+                groupsData: groupsData
+            }); // put == UPSERT
+            return txnAsPromise(txn);
+        });
+    },
+
+    /**
+     * Persist a list of account data events. Events with the same 'type' will
+     * be replaced.
+     * @param {Object[]} accountData An array of raw user-scoped account data events
+     * @return {Promise} Resolves if the events were persisted.
+     */
+    _persistAccountData: function (accountData) {
+        return _bluebird2.default.try(() => {
+            const txn = this.db.transaction(["accountData"], "readwrite");
+            const store = txn.objectStore("accountData");
+            for (let i = 0; i < accountData.length; i++) {
+                store.put(accountData[i]); // put == UPSERT
+            }
+            return txnAsPromise(txn);
+        });
+    },
+
+    /**
+     * Persist a list of [user id, presence event] they are for.
+     * Users with the same 'userId' will be replaced.
+     * Presence events should be the event in its raw form (not the Event
+     * object)
+     * @param {Object[]} tuples An array of [userid, event] tuples
+     * @return {Promise} Resolves if the users were persisted.
+     */
+    _persistUserPresenceEvents: function (tuples) {
+        return _bluebird2.default.try(() => {
+            const txn = this.db.transaction(["users"], "readwrite");
+            const store = txn.objectStore("users");
+            for (const tuple of tuples) {
+                store.put({
+                    userId: tuple[0],
+                    event: tuple[1]
+                }); // put == UPSERT
+            }
+            return txnAsPromise(txn);
+        });
+    },
+
+    /**
+     * Load all user presence events from the database. This is not cached.
+     * FIXME: It would probably be more sensible to store the events in the
+     * sync.
+     * @return {Promise<Object[]>} A list of presence events in their raw form.
+     */
+    getUserPresenceEvents: function () {
+        return _bluebird2.default.try(() => {
+            const txn = this.db.transaction(["users"], "readonly");
+            const store = txn.objectStore("users");
+            return selectQuery(store, undefined, cursor => {
+                return [cursor.value.userId, cursor.value.event];
+            });
+        });
+    },
+
+    /**
+     * Load all the account data events from the database. This is not cached.
+     * @return {Promise<Object[]>} A list of raw global account events.
+     */
+    _loadAccountData: function () {
+        _logger2.default.log(`LocalIndexedDBStoreBackend: loading account data...`);
+        return _bluebird2.default.try(() => {
+            const txn = this.db.transaction(["accountData"], "readonly");
+            const store = txn.objectStore("accountData");
+            return selectQuery(store, undefined, cursor => {
+                return cursor.value;
+            }).then(result => {
+                _logger2.default.log(`LocalIndexedDBStoreBackend: loaded account data`);
+                return result;
+            });
+        });
+    },
+
+    /**
+     * Load the sync data from the database.
+     * @return {Promise<Object>} An object with "roomsData" and "nextBatch" keys.
+     */
+    _loadSyncData: function () {
+        _logger2.default.log(`LocalIndexedDBStoreBackend: loading sync data...`);
+        return _bluebird2.default.try(() => {
+            const txn = this.db.transaction(["sync"], "readonly");
+            const store = txn.objectStore("sync");
+            return selectQuery(store, undefined, cursor => {
+                return cursor.value;
+            }).then(results => {
+                _logger2.default.log(`LocalIndexedDBStoreBackend: loaded sync data`);
+                if (results.length > 1) {
+                    _logger2.default.warn("loadSyncData: More than 1 sync row found.");
+                }
+                return results.length > 0 ? results[0] : {};
+            });
+        });
+    },
+
+    getClientOptions: function () {
+        return _bluebird2.default.resolve().then(() => {
+            const txn = this.db.transaction(["client_options"], "readonly");
+            const store = txn.objectStore("client_options");
+            return selectQuery(store, undefined, cursor => {
+                if (cursor.value && cursor.value && cursor.value.options) {
+                    return cursor.value.options;
+                }
+            }).then(results => results[0]);
+        });
+    },
+
+    storeClientOptions: async function (options) {
+        const txn = this.db.transaction(["client_options"], "readwrite");
+        const store = txn.objectStore("client_options");
+        store.put({
+            clobber: "-", // constant key so will always clobber
+            options: options
+        }); // put == UPSERT
+        await txnAsPromise(txn);
+    }
+};
+
+exports.default = LocalIndexedDBStoreBackend;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-remote-backend.js
@@ -0,0 +1,208 @@
+'use strict';
+
+Object.defineProperty(exports, "__esModule", {
+    value: true
+});
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require('../../src/logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * An IndexedDB store backend where the actual backend sits in a web
+ * worker.
+ *
+ * Construct a new Indexed Database store backend. This requires a call to
+ * <code>connect()</code> before this store can be used.
+ * @constructor
+ * @param {string} workerScript URL to the worker script
+ * @param {string=} dbName Optional database name. The same name must be used
+ * to open the same database.
+ * @param {Object} workerApi The web worker compatible interface object
+ */
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+const RemoteIndexedDBStoreBackend = function RemoteIndexedDBStoreBackend(workerScript, dbName, workerApi) {
+    this._workerScript = workerScript;
+    this._dbName = dbName;
+    this._workerApi = workerApi;
+    this._worker = null;
+    this._nextSeq = 0;
+    // The currently in-flight requests to the actual backend
+    this._inFlight = {
+        // seq: promise,
+    };
+    // Once we start connecting, we keep the promise and re-use it
+    // if we try to connect again
+    this._startPromise = null;
+};
+
+RemoteIndexedDBStoreBackend.prototype = {
+    /**
+     * Attempt to connect to the database. This can fail if the user does not
+     * grant permission.
+     * @return {Promise} Resolves if successfully connected.
+     */
+    connect: function () {
+        return this._ensureStarted().then(() => this._doCmd('connect'));
+    },
+
+    /**
+     * Clear the entire database. This should be used when logging out of a client
+     * to prevent mixing data between accounts.
+     * @return {Promise} Resolved when the database is cleared.
+     */
+    clearDatabase: function () {
+        return this._ensureStarted().then(() => this._doCmd('clearDatabase'));
+    },
+    /** @return {Promise<bool>} whether or not the database was newly created in this session. */
+    isNewlyCreated: function () {
+        return this._doCmd('isNewlyCreated');
+    },
+    /**
+     * @return {Promise} Resolves with a sync response to restore the
+     * client state to where it was at the last save, or null if there
+     * is no saved sync data.
+     */
+    getSavedSync: function () {
+        return this._doCmd('getSavedSync');
+    },
+
+    getNextBatchToken: function () {
+        return this._doCmd('getNextBatchToken');
+    },
+
+    setSyncData: function (syncData) {
+        return this._doCmd('setSyncData', [syncData]);
+    },
+
+    syncToDatabase: function (users) {
+        return this._doCmd('syncToDatabase', [users]);
+    },
+
+    /**
+     * Returns the out-of-band membership events for this room that
+     * were previously loaded.
+     * @param {string} roomId
+     * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
+     * @returns {null} in case the members for this room haven't been stored yet
+     */
+    getOutOfBandMembers: function (roomId) {
+        return this._doCmd('getOutOfBandMembers', [roomId]);
+    },
+
+    /**
+     * Stores the out-of-band membership events for this room. Note that
+     * it still makes sense to store an empty array as the OOB status for the room is
+     * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+     * @param {string} roomId
+     * @param {event[]} membershipEvents the membership events to store
+     * @returns {Promise} when all members have been stored
+     */
+    setOutOfBandMembers: function (roomId, membershipEvents) {
+        return this._doCmd('setOutOfBandMembers', [roomId, membershipEvents]);
+    },
+
+    clearOutOfBandMembers: function (roomId) {
+        return this._doCmd('clearOutOfBandMembers', [roomId]);
+    },
+
+    getClientOptions: function () {
+        return this._doCmd('getClientOptions');
+    },
+
+    storeClientOptions: function (options) {
+        return this._doCmd('storeClientOptions', [options]);
+    },
+
+    /**
+     * Load all user presence events from the database. This is not cached.
+     * @return {Promise<Object[]>} A list of presence events in their raw form.
+     */
+    getUserPresenceEvents: function () {
+        return this._doCmd('getUserPresenceEvents');
+    },
+
+    _ensureStarted: function () {
+        if (this._startPromise === null) {
+            this._worker = new this._workerApi(this._workerScript);
+            this._worker.onmessage = this._onWorkerMessage.bind(this);
+
+            // tell the worker the db name.
+            this._startPromise = this._doCmd('_setupWorker', [this._dbName]).then(() => {
+                _logger2.default.log("IndexedDB worker is ready");
+            });
+        }
+        return this._startPromise;
+    },
+
+    _doCmd: function (cmd, args) {
+        // wrap in a q so if the postMessage throws,
+        // the promise automatically gets rejected
+        return _bluebird2.default.resolve().then(() => {
+            const seq = this._nextSeq++;
+            const def = _bluebird2.default.defer();
+
+            this._inFlight[seq] = def;
+
+            this._worker.postMessage({
+                command: cmd,
+                seq: seq,
+                args: args
+            });
+
+            return def.promise;
+        });
+    },
+
+    _onWorkerMessage: function (ev) {
+        const msg = ev.data;
+
+        if (msg.command == 'cmd_success' || msg.command == 'cmd_fail') {
+            if (msg.seq === undefined) {
+                _logger2.default.error("Got reply from worker with no seq");
+                return;
+            }
+
+            const def = this._inFlight[msg.seq];
+            if (def === undefined) {
+                _logger2.default.error("Got reply for unknown seq " + msg.seq);
+                return;
+            }
+            delete this._inFlight[msg.seq];
+
+            if (msg.command == 'cmd_success') {
+                def.resolve(msg.result);
+            } else {
+                const error = new Error(msg.error.message);
+                error.name = msg.error.name;
+                def.reject(error);
+            }
+        } else {
+            _logger2.default.warn("Unrecognised message from worker: " + msg);
+        }
+    }
+};
+
+exports.default = RemoteIndexedDBStoreBackend;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb-store-worker.js
@@ -0,0 +1,157 @@
+'use strict';
+
+var _bluebird = require('bluebird');
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _indexeddbLocalBackend = require('./indexeddb-local-backend.js');
+
+var _indexeddbLocalBackend2 = _interopRequireDefault(_indexeddbLocalBackend);
+
+var _logger = require('../../src/logger');
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * This class lives in the webworker and drives a LocalIndexedDBStoreBackend
+ * controlled by messages from the main process.
+ *
+ * It should be instantiated by a web worker script provided by the application
+ * in a script, for example:
+ *
+ * import {IndexedDBStoreWorker} from 'matrix-js-sdk/lib/indexeddb-worker.js';
+ * const remoteWorker = new IndexedDBStoreWorker(postMessage);
+ * onmessage = remoteWorker.onMessage;
+ *
+ * Note that it is advisable to import this class by referencing the file directly to
+ * avoid a dependency on the whole js-sdk.
+ *
+ */
+class IndexedDBStoreWorker {
+    /**
+     * @param {function} postMessage The web worker postMessage function that
+     * should be used to communicate back to the main script.
+     */
+    constructor(postMessage) {
+        this.backend = null;
+        this.postMessage = postMessage;
+
+        this.onMessage = this.onMessage.bind(this);
+    }
+
+    /**
+     * Passes a message event from the main script into the class. This method
+     * can be directly assigned to the web worker `onmessage` variable.
+     *
+     * @param {Object} ev The message event
+     */
+    onMessage(ev) {
+        const msg = ev.data;
+        let prom;
+
+        switch (msg.command) {
+            case '_setupWorker':
+                this.backend = new _indexeddbLocalBackend2.default(
+                // this is the 'indexedDB' global (where global != window
+                // because it's a web worker and there is no window).
+                indexedDB, msg.args[0]);
+                prom = _bluebird2.default.resolve();
+                break;
+            case 'connect':
+                prom = this.backend.connect();
+                break;
+            case 'isNewlyCreated':
+                prom = this.backend.isNewlyCreated();
+                break;
+            case 'clearDatabase':
+                prom = this.backend.clearDatabase().then(result => {
+                    // This returns special classes which can't be cloned
+                    // across to the main script, so don't try.
+                    return {};
+                });
+                break;
+            case 'getSavedSync':
+                prom = this.backend.getSavedSync(false);
+                break;
+            case 'setSyncData':
+                prom = this.backend.setSyncData(...msg.args);
+                break;
+            case 'syncToDatabase':
+                prom = this.backend.syncToDatabase(...msg.args).then(() => {
+                    // This also returns IndexedDB events which are not cloneable
+                    return {};
+                });
+                break;
+            case 'getUserPresenceEvents':
+                prom = this.backend.getUserPresenceEvents();
+                break;
+            case 'getNextBatchToken':
+                prom = this.backend.getNextBatchToken();
+                break;
+            case 'getOutOfBandMembers':
+                prom = this.backend.getOutOfBandMembers(msg.args[0]);
+                break;
+            case 'clearOutOfBandMembers':
+                prom = this.backend.clearOutOfBandMembers(msg.args[0]);
+                break;
+            case 'setOutOfBandMembers':
+                prom = this.backend.setOutOfBandMembers(msg.args[0], msg.args[1]);
+                break;
+            case 'getClientOptions':
+                prom = this.backend.getClientOptions();
+                break;
+            case 'storeClientOptions':
+                prom = this.backend.storeClientOptions(msg.args[0]);
+                break;
+        }
+
+        if (prom === undefined) {
+            this.postMessage({
+                command: 'cmd_fail',
+                seq: msg.seq,
+                // Can't be an Error because they're not structured cloneable
+                error: "Unrecognised command"
+            });
+            return;
+        }
+
+        prom.done(ret => {
+            this.postMessage.call(null, {
+                command: 'cmd_success',
+                seq: msg.seq,
+                result: ret
+            });
+        }, err => {
+            _logger2.default.error("Error running command: " + msg.command);
+            _logger2.default.error(err);
+            this.postMessage.call(null, {
+                command: 'cmd_fail',
+                seq: msg.seq,
+                // Just send a string because Error objects aren't cloneable
+                error: {
+                    message: err.message,
+                    name: err.name
+                }
+            });
+        });
+    }
+} /*
+  Copyright 2017 Vector Creations Ltd
+  Copyright 2018 New Vector Ltd
+  
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+  
+      http://www.apache.org/licenses/LICENSE-2.0
+  
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  */
+
+module.exports = IndexedDBStoreWorker;
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/indexeddb.js
@@ -0,0 +1,341 @@
+"use strict";
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _memory = require("./memory");
+
+var _utils = require("../utils");
+
+var _utils2 = _interopRequireDefault(_utils);
+
+var _events = require("events");
+
+var _indexeddbLocalBackend = require("./indexeddb-local-backend.js");
+
+var _indexeddbLocalBackend2 = _interopRequireDefault(_indexeddbLocalBackend);
+
+var _indexeddbRemoteBackend = require("./indexeddb-remote-backend.js");
+
+var _indexeddbRemoteBackend2 = _interopRequireDefault(_indexeddbRemoteBackend);
+
+var _user = require("../models/user");
+
+var _user2 = _interopRequireDefault(_user);
+
+var _event = require("../models/event");
+
+var _logger = require("../../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * This is an internal module. See {@link IndexedDBStore} for the public class.
+ * @module store/indexeddb
+ */
+
+// If this value is too small we'll be writing very often which will cause
+// noticable stop-the-world pauses. If this value is too big we'll be writing
+// so infrequently that the /sync size gets bigger on reload. Writing more
+// often does not affect the length of the pause since the entire /sync
+// response is persisted each time.
+const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes
+
+
+/**
+ * Construct a new Indexed Database store, which extends MemoryStore.
+ *
+ * This store functions like a MemoryStore except it periodically persists
+ * the contents of the store to an IndexedDB backend.
+ *
+ * All data is still kept in-memory but can be loaded from disk by calling
+ * <code>startup()</code>. This can make startup times quicker as a complete
+ * sync from the server is not required. This does not reduce memory usage as all
+ * the data is eagerly fetched when <code>startup()</code> is called.
+ * <pre>
+ * let opts = { localStorage: window.localStorage };
+ * let store = new IndexedDBStore();
+ * await store.startup(); // load from indexed db
+ * let client = sdk.createClient({
+ *     store: store,
+ * });
+ * client.startClient();
+ * client.on("sync", function(state, prevState, data) {
+ *     if (state === "PREPARED") {
+ *         console.log("Started up, now with go faster stripes!");
+ *     }
+ * });
+ * </pre>
+ *
+ * @constructor
+ * @extends MemoryStore
+ * @param {Object} opts Options object.
+ * @param {Object} opts.indexedDB The Indexed DB interface e.g.
+ * <code>window.indexedDB</code>
+ * @param {string=} opts.dbName Optional database name. The same name must be used
+ * to open the same database.
+ * @param {string=} opts.workerScript Optional URL to a script to invoke a web
+ * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker
+ * class is provided for this purpose and requires the application to provide a
+ * trivial wrapper script around it.
+ * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker
+ * object will be used if it exists.
+ * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to
+ * this API if you need to perform specific indexeddb actions like deleting the
+ * database.
+ */
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* eslint-disable babel/no-invalid-this */
+
+const IndexedDBStore = function IndexedDBStore(opts) {
+    _memory.MemoryStore.call(this, opts);
+
+    if (!opts.indexedDB) {
+        throw new Error('Missing required option: indexedDB');
+    }
+
+    if (opts.workerScript) {
+        // try & find a webworker-compatible API
+        let workerApi = opts.workerApi;
+        if (!workerApi) {
+            // default to the global Worker object (which is where it in a browser)
+            workerApi = global.Worker;
+        }
+        this.backend = new _indexeddbRemoteBackend2.default(opts.workerScript, opts.dbName, workerApi);
+    } else {
+        this.backend = new _indexeddbLocalBackend2.default(opts.indexedDB, opts.dbName);
+    }
+
+    this.startedUp = false;
+    this._syncTs = 0;
+
+    // Records the last-modified-time of each user at the last point we saved
+    // the database, such that we can derive the set if users that have been
+    // modified since we last saved.
+    this._userModifiedMap = {
+        // user_id : timestamp
+    };
+};
+_utils2.default.inherits(IndexedDBStore, _memory.MemoryStore);
+_utils2.default.extend(IndexedDBStore.prototype, _events.EventEmitter.prototype);
+
+IndexedDBStore.exists = function (indexedDB, dbName) {
+    return _indexeddbLocalBackend2.default.exists(indexedDB, dbName);
+};
+
+/**
+ * @return {Promise} Resolved when loaded from indexed db.
+  */
+IndexedDBStore.prototype.startup = function () {
+    if (this.startedUp) {
+        _logger2.default.log(`IndexedDBStore.startup: already started`);
+        return _bluebird2.default.resolve();
+    }
+
+    _logger2.default.log(`IndexedDBStore.startup: connecting to backend`);
+    return this.backend.connect().then(() => {
+        _logger2.default.log(`IndexedDBStore.startup: loading presence events`);
+        return this.backend.getUserPresenceEvents();
+    }).then(userPresenceEvents => {
+        _logger2.default.log(`IndexedDBStore.startup: processing presence events`);
+        userPresenceEvents.forEach(([userId, rawEvent]) => {
+            const u = new _user2.default(userId);
+            if (rawEvent) {
+                u.setPresenceEvent(new _event.MatrixEvent(rawEvent));
+            }
+            this._userModifiedMap[u.userId] = u.getLastModifiedTime();
+            this.storeUser(u);
+        });
+    });
+};
+
+/**
+ * @return {Promise} Resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+IndexedDBStore.prototype.getSavedSync = degradable(function () {
+    return this.backend.getSavedSync();
+}, "getSavedSync");
+
+/** @return {Promise<bool>} whether or not the database was newly created in this session. */
+IndexedDBStore.prototype.isNewlyCreated = degradable(function () {
+    return this.backend.isNewlyCreated();
+}, "isNewlyCreated");
+
+/**
+ * @return {Promise} If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+IndexedDBStore.prototype.getSavedSyncToken = degradable(function () {
+    return this.backend.getNextBatchToken();
+}, "getSavedSyncToken"),
+
+/**
+ * Delete all data from this store.
+ * @return {Promise} Resolves if the data was deleted from the database.
+ */
+IndexedDBStore.prototype.deleteAllData = degradable(function () {
+    _memory.MemoryStore.prototype.deleteAllData.call(this);
+    return this.backend.clearDatabase().then(() => {
+        _logger2.default.log("Deleted indexeddb data.");
+    }, err => {
+        _logger2.default.error(`Failed to delete indexeddb data: ${err}`);
+        throw err;
+    });
+});
+
+/**
+ * Whether this store would like to save its data
+ * Note that obviously whether the store wants to save or
+ * not could change between calling this function and calling
+ * save().
+ *
+ * @return {boolean} True if calling save() will actually save
+ *     (at the time this function is called).
+ */
+IndexedDBStore.prototype.wantsSave = function () {
+    const now = Date.now();
+    return now - this._syncTs > WRITE_DELAY_MS;
+};
+
+/**
+ * Possibly write data to the database.
+ *
+ * @param {bool} force True to force a save to happen
+ * @return {Promise} Promise resolves after the write completes
+ *     (or immediately if no write is performed)
+ */
+IndexedDBStore.prototype.save = function (force) {
+    if (force || this.wantsSave()) {
+        return this._reallySave();
+    }
+    return _bluebird2.default.resolve();
+};
+
+IndexedDBStore.prototype._reallySave = degradable(function () {
+    this._syncTs = Date.now(); // set now to guard against multi-writes
+
+    // work out changed users (this doesn't handle deletions but you
+    // can't 'delete' users as they are just presence events).
+    const userTuples = [];
+    for (const u of this.getUsers()) {
+        if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue;
+        if (!u.events.presence) continue;
+
+        userTuples.push([u.userId, u.events.presence.event]);
+
+        // note that we've saved this version of the user
+        this._userModifiedMap[u.userId] = u.getLastModifiedTime();
+    }
+
+    return this.backend.syncToDatabase(userTuples);
+});
+
+IndexedDBStore.prototype.setSyncData = degradable(function (syncData) {
+    return this.backend.setSyncData(syncData);
+}, "setSyncData");
+
+/**
+ * Returns the out-of-band membership events for this room that
+ * were previously loaded.
+ * @param {string} roomId
+ * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
+ * @returns {null} in case the members for this room haven't been stored yet
+ */
+IndexedDBStore.prototype.getOutOfBandMembers = degradable(function (roomId) {
+    return this.backend.getOutOfBandMembers(roomId);
+}, "getOutOfBandMembers");
+
+/**
+ * Stores the out-of-band membership events for this room. Note that
+ * it still makes sense to store an empty array as the OOB status for the room is
+ * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+ * @param {string} roomId
+ * @param {event[]} membershipEvents the membership events to store
+ * @returns {Promise} when all members have been stored
+ */
+IndexedDBStore.prototype.setOutOfBandMembers = degradable(function (roomId, membershipEvents) {
+    _memory.MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents);
+    return this.backend.setOutOfBandMembers(roomId, membershipEvents);
+}, "setOutOfBandMembers");
+
+IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function (roomId) {
+    _memory.MemoryStore.prototype.clearOutOfBandMembers.call(this);
+    return this.backend.clearOutOfBandMembers(roomId);
+}, "clearOutOfBandMembers");
+
+IndexedDBStore.prototype.getClientOptions = degradable(function () {
+    return this.backend.getClientOptions();
+}, "getClientOptions");
+
+IndexedDBStore.prototype.storeClientOptions = degradable(function (options) {
+    _memory.MemoryStore.prototype.storeClientOptions.call(this, options);
+    return this.backend.storeClientOptions(options);
+}, "storeClientOptions");
+
+module.exports.IndexedDBStore = IndexedDBStore;
+
+/**
+ * All member functions of `IndexedDBStore` that access the backend use this wrapper to
+ * watch for failures after initial store startup, including `QuotaExceededError` as
+ * free disk space changes, etc.
+ *
+ * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore`
+ * in place so that the current operation and all future ones are in-memory only.
+ *
+ * @param {Function} func The degradable work to do.
+ * @param {String} fallback The method name for fallback.
+ * @returns {Function} A wrapped member function.
+ */
+function degradable(func, fallback) {
+    return async function (...args) {
+        try {
+            return await func.call(this, ...args);
+        } catch (e) {
+            _logger2.default.error("IndexedDBStore failure, degrading to MemoryStore", e);
+            this.emit("degraded", e);
+            try {
+                // We try to delete IndexedDB after degrading since this store is only a
+                // cache (the app will still function correctly without the data).
+                // It's possible that deleting repair IndexedDB for the next app load,
+                // potenially by making a little more space available.
+                _logger2.default.log("IndexedDBStore trying to delete degraded data");
+                await this.backend.clearDatabase();
+                _logger2.default.log("IndexedDBStore delete after degrading succeeeded");
+            } catch (e) {
+                _logger2.default.warn("IndexedDBStore delete after degrading failed", e);
+            }
+            // Degrade the store from being an instance of `IndexedDBStore` to instead be
+            // an instance of `MemoryStore` so that future API calls use the memory path
+            // directly and skip IndexedDB entirely. This should be safe as
+            // `IndexedDBStore` already extends from `MemoryStore`, so we are making the
+            // store become its parent type in a way. The mutator methods of
+            // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are
+            // not overridden at all).
+            Object.setPrototypeOf(this, _memory.MemoryStore.prototype);
+            if (fallback) {
+                return await _memory.MemoryStore.prototype[fallback].call(this, ...args);
+            }
+        }
+    };
+}
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/store/memory.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/memory.js
@@ -1,284 +1,433 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
- * This is an internal module. See {@link MatrixInMemoryStore} for the public class.
+ * This is an internal module. See {@link MemoryStore} for the public class.
  * @module store/memory
  */
- var utils = require("../utils");
- var User = require("../models/user");
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const utils = require("../utils");
+const User = require("../models/user");
+
 
 /**
  * Construct a new in-memory data store for the Matrix Client.
  * @constructor
  * @param {Object=} opts Config options
  * @param {LocalStorage} opts.localStorage The local storage instance to persist
- * some forms of data such as tokens. Rooms will NOT be stored. See
- * {@link WebStorageStore} to persist rooms.
+ * some forms of data such as tokens. Rooms will NOT be stored.
  */
-module.exports.MatrixInMemoryStore = function MatrixInMemoryStore(opts) {
+module.exports.MemoryStore = function MemoryStore(opts) {
     opts = opts || {};
     this.rooms = {
         // roomId: Room
     };
+    this.groups = {
+        // groupId: Group
+    };
     this.users = {
         // userId: User
     };
     this.syncToken = null;
     this.filters = {
         // userId: {
         //    filterId: Filter
         // }
     };
     this.accountData = {
         // type : content
     };
     this.localStorage = opts.localStorage;
+    this._oobMembers = {
+        // roomId: [member events]
+    };
+    this._clientOptions = {};
 };
 
-module.exports.MatrixInMemoryStore.prototype = {
+module.exports.MemoryStore.prototype = {
 
     /**
      * Retrieve the token to stream from.
      * @return {string} The token or null.
      */
-    getSyncToken: function() {
+    getSyncToken: function () {
         return this.syncToken;
     },
 
+    /** @return {Promise<bool>} whether or not the database was newly created in this session. */
+    isNewlyCreated: function () {
+        return _bluebird2.default.resolve(true);
+    },
 
     /**
      * Set the token to stream from.
      * @param {string} token The token to stream from.
      */
-    setSyncToken: function(token) {
+    setSyncToken: function (token) {
         this.syncToken = token;
     },
 
     /**
      * Store the given room.
+     * @param {Group} group The group to be stored
+     */
+    storeGroup: function (group) {
+        this.groups[group.groupId] = group;
+    },
+
+    /**
+     * Retrieve a group by its group ID.
+     * @param {string} groupId The group ID.
+     * @return {Group} The group or null.
+     */
+    getGroup: function (groupId) {
+        return this.groups[groupId] || null;
+    },
+
+    /**
+     * Retrieve all known groups.
+     * @return {Group[]} A list of groups, which may be empty.
+     */
+    getGroups: function () {
+        return utils.values(this.groups);
+    },
+
+    /**
+     * Store the given room.
      * @param {Room} room The room to be stored. All properties must be stored.
      */
-    storeRoom: function(room) {
+    storeRoom: function (room) {
         this.rooms[room.roomId] = room;
         // add listeners for room member changes so we can keep the room member
         // map up-to-date.
         room.currentState.on("RoomState.members", this._onRoomMember.bind(this));
         // add existing members
-        var self = this;
-        room.currentState.getMembers().forEach(function(m) {
+        const self = this;
+        room.currentState.getMembers().forEach(function (m) {
             self._onRoomMember(null, room.currentState, m);
         });
     },
 
     /**
      * Called when a room member in a room being tracked by this store has been
      * updated.
      * @param {MatrixEvent} event
      * @param {RoomState} state
      * @param {RoomMember} member
      */
-    _onRoomMember: function(event, state, member) {
+    _onRoomMember: function (event, state, member) {
         if (member.membership === "invite") {
             // We do NOT add invited members because people love to typo user IDs
             // which would then show up in these lists (!)
             return;
         }
 
-        var user = this.users[member.userId] || new User(member.userId);
+        const user = this.users[member.userId] || new User(member.userId);
         if (member.name) {
             user.setDisplayName(member.name);
             if (member.events.member) {
-                user.setRawDisplayName(
-                    member.events.member.getDirectionalContent().displayname
-                );
+                user.setRawDisplayName(member.events.member.getDirectionalContent().displayname);
             }
         }
         if (member.events.member && member.events.member.getContent().avatar_url) {
             user.setAvatarUrl(member.events.member.getContent().avatar_url);
         }
         this.users[user.userId] = user;
     },
 
     /**
      * Retrieve a room by its' room ID.
      * @param {string} roomId The room ID.
      * @return {Room} The room or null.
      */
-    getRoom: function(roomId) {
+    getRoom: function (roomId) {
         return this.rooms[roomId] || null;
     },
 
     /**
      * Retrieve all known rooms.
      * @return {Room[]} A list of rooms, which may be empty.
      */
-    getRooms: function() {
+    getRooms: function () {
         return utils.values(this.rooms);
     },
 
     /**
      * Permanently delete a room.
      * @param {string} roomId
      */
-    removeRoom: function(roomId) {
+    removeRoom: function (roomId) {
         if (this.rooms[roomId]) {
             this.rooms[roomId].removeListener("RoomState.members", this._onRoomMember);
         }
         delete this.rooms[roomId];
     },
 
     /**
      * Retrieve a summary of all the rooms.
      * @return {RoomSummary[]} A summary of each room.
      */
-    getRoomSummaries: function() {
-        return utils.map(utils.values(this.rooms), function(room) {
+    getRoomSummaries: function () {
+        return utils.map(utils.values(this.rooms), function (room) {
             return room.summary;
         });
     },
 
     /**
      * Store a User.
      * @param {User} user The user to store.
      */
-    storeUser: function(user) {
+    storeUser: function (user) {
         this.users[user.userId] = user;
     },
 
     /**
      * Retrieve a User by its' user ID.
      * @param {string} userId The user ID.
      * @return {User} The user or null.
      */
-    getUser: function(userId) {
+    getUser: function (userId) {
         return this.users[userId] || null;
     },
 
     /**
      * Retrieve all known users.
      * @return {User[]} A list of users, which may be empty.
      */
-    getUsers: function() {
+    getUsers: function () {
         return utils.values(this.users);
     },
 
     /**
      * Retrieve scrollback for this room.
      * @param {Room} room The matrix room
      * @param {integer} limit The max number of old events to retrieve.
      * @return {Array<Object>} An array of objects which will be at most 'limit'
      * length and at least 0. The objects are the raw event JSON.
      */
-    scrollback: function(room, limit) {
+    scrollback: function (room, limit) {
         return [];
     },
 
     /**
      * Store events for a room. The events have already been added to the timeline
      * @param {Room} room The room to store events for.
      * @param {Array<MatrixEvent>} events The events to store.
      * @param {string} token The token associated with these events.
      * @param {boolean} toStart True if these are paginated results.
      */
-    storeEvents: function(room, events, token, toStart) {
+    storeEvents: function (room, events, token, toStart) {
         // no-op because they've already been added to the room instance.
     },
 
     /**
      * Store a filter.
      * @param {Filter} filter
      */
-    storeFilter: function(filter) {
-        if (!filter) { return; }
+    storeFilter: function (filter) {
+        if (!filter) {
+            return;
+        }
         if (!this.filters[filter.userId]) {
             this.filters[filter.userId] = {};
         }
         this.filters[filter.userId][filter.filterId] = filter;
     },
 
     /**
      * Retrieve a filter.
      * @param {string} userId
      * @param {string} filterId
      * @return {?Filter} A filter or null.
      */
-    getFilter: function(userId, filterId) {
+    getFilter: function (userId, filterId) {
         if (!this.filters[userId] || !this.filters[userId][filterId]) {
             return null;
         }
         return this.filters[userId][filterId];
     },
 
     /**
      * Retrieve a filter ID with the given name.
      * @param {string} filterName The filter name.
      * @return {?string} The filter ID or null.
      */
-    getFilterIdByName: function(filterName) {
+    getFilterIdByName: function (filterName) {
         if (!this.localStorage) {
             return null;
         }
         try {
             return this.localStorage.getItem("mxjssdk_memory_filter_" + filterName);
-        }
-        catch (e) {}
+        } catch (e) {}
         return null;
     },
 
     /**
      * Set a filter name to ID mapping.
      * @param {string} filterName
      * @param {string} filterId
      */
-    setFilterIdByName: function(filterName, filterId) {
+    setFilterIdByName: function (filterName, filterId) {
         if (!this.localStorage) {
             return;
         }
         try {
             this.localStorage.setItem("mxjssdk_memory_filter_" + filterName, filterId);
-        }
-        catch (e) {}
+        } catch (e) {}
     },
 
     /**
      * Store user-scoped account data events.
      * N.B. that account data only allows a single event per type, so multiple
      * events with the same type will replace each other.
      * @param {Array<MatrixEvent>} events The events to store.
      */
-    storeAccountDataEvents: function(events) {
-        var self = this;
-        events.forEach(function(event) {
+    storeAccountDataEvents: function (events) {
+        const self = this;
+        events.forEach(function (event) {
             self.accountData[event.getType()] = event;
         });
     },
 
     /**
      * Get account data event by event type
      * @param {string} eventType The event type being queried
      * @return {?MatrixEvent} the user account_data event of given type, if any
      */
-    getAccountData: function(eventType) {
+    getAccountData: function (eventType) {
         return this.accountData[eventType];
     },
 
-    // TODO
-    //setMaxHistoryPerRoom: function(maxHistory) {},
+    /**
+     * setSyncData does nothing as there is no backing data store.
+     *
+     * @param {Object} syncData The sync data
+     * @return {Promise} An immediately resolved promise.
+     */
+    setSyncData: function (syncData) {
+        return _bluebird2.default.resolve();
+    },
+
+    /**
+     * We never want to save becase we have nothing to save to.
+     *
+     * @return {boolean} If the store wants to save
+     */
+    wantsSave: function () {
+        return false;
+    },
+
+    /**
+     * Save does nothing as there is no backing data store.
+     * @param {bool} force True to force a save (but the memory
+     *     store still can't save anything)
+     */
+    save: function (force) {},
+
+    /**
+     * Startup does nothing as this store doesn't require starting up.
+     * @return {Promise} An immediately resolved promise.
+     */
+    startup: function () {
+        return _bluebird2.default.resolve();
+    },
+
+    /**
+     * @return {Promise} Resolves with a sync response to restore the
+     * client state to where it was at the last save, or null if there
+     * is no saved sync data.
+     */
+    getSavedSync: function () {
+        return _bluebird2.default.resolve(null);
+    },
+
+    /**
+     * @return {Promise} If there is a saved sync, the nextBatch token
+     * for this sync, otherwise null.
+     */
+    getSavedSyncToken: function () {
+        return _bluebird2.default.resolve(null);
+    },
 
-    // TODO
-    //reapOldMessages: function() {},
-};
+    /**
+     * Delete all data from this store.
+     * @return {Promise} An immediately resolved promise.
+     */
+    deleteAllData: function () {
+        this.rooms = {
+            // roomId: Room
+        };
+        this.users = {
+            // userId: User
+        };
+        this.syncToken = null;
+        this.filters = {
+            // userId: {
+            //    filterId: Filter
+            // }
+        };
+        this.accountData = {
+            // type : content
+        };
+        return _bluebird2.default.resolve();
+    },
+
+    /**
+     * Returns the out-of-band membership events for this room that
+     * were previously loaded.
+     * @param {string} roomId
+     * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
+     * @returns {null} in case the members for this room haven't been stored yet
+     */
+    getOutOfBandMembers: function (roomId) {
+        return _bluebird2.default.resolve(this._oobMembers[roomId] || null);
+    },
+
+    /**
+     * Stores the out-of-band membership events for this room. Note that
+     * it still makes sense to store an empty array as the OOB status for the room is
+     * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+     * @param {string} roomId
+     * @param {event[]} membershipEvents the membership events to store
+     * @returns {Promise} when all members have been stored
+     */
+    setOutOfBandMembers: function (roomId, membershipEvents) {
+        this._oobMembers[roomId] = membershipEvents;
+        return _bluebird2.default.resolve();
+    },
+
+    clearOutOfBandMembers: function () {
+        this._oobMembers = {};
+        return _bluebird2.default.resolve();
+    },
+
+    getClientOptions: function () {
+        return _bluebird2.default.resolve(this._clientOptions);
+    },
+
+    storeClientOptions: function (options) {
+        this._clientOptions = Object.assign({}, options);
+        return _bluebird2.default.resolve();
+    }
+};
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/store/session/webstorage.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/session/webstorage.js
@@ -1,10 +1,12 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 New Vector Ltd
+Copyright 2018 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -14,148 +16,201 @@ See the License for the specific languag
 limitations under the License.
 */
 "use strict";
 
 /**
  * @module store/session/webstorage
  */
 
-var utils = require("../../utils");
+var _logger = require("../../logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 
-var DEBUG = false;  // set true to enable console logging.
-var E2E_PREFIX = "session.e2e.";
+const utils = require("../../utils");
+
+
+const DEBUG = false; // set true to enable console logging.
+const E2E_PREFIX = "session.e2e.";
 
 /**
  * Construct a web storage session store, capable of storing account keys,
  * session keys and access tokens.
  * @constructor
  * @param {WebStorage} webStore A web storage implementation, e.g.
  * 'window.localStorage' or 'window.sessionStorage' or a custom implementation.
  * @throws if the supplied 'store' does not meet the Storage interface of the
  * WebStorage API.
  */
 function WebStorageSessionStore(webStore) {
     this.store = webStore;
-    if (!utils.isFunction(webStore.getItem) ||
-        !utils.isFunction(webStore.setItem) ||
-        !utils.isFunction(webStore.removeItem)) {
-        throw new Error(
-            "Supplied webStore does not meet the WebStorage API interface"
-        );
+    if (!utils.isFunction(webStore.getItem) || !utils.isFunction(webStore.setItem) || !utils.isFunction(webStore.removeItem) || !utils.isFunction(webStore.key) || typeof webStore.length !== 'number') {
+        throw new Error("Supplied webStore does not meet the WebStorage API interface");
     }
 }
 
 WebStorageSessionStore.prototype = {
-
     /**
-     * Store the end to end account for the logged-in user.
-     * @param {string} account Base64 encoded account.
+     * Remove the stored end to end account for the logged-in user.
      */
-    storeEndToEndAccount: function(account) {
-        this.store.setItem(KEY_END_TO_END_ACCOUNT, account);
+    removeEndToEndAccount: function () {
+        this.store.removeItem(KEY_END_TO_END_ACCOUNT);
     },
 
     /**
      * Load the end to end account for the logged-in user.
+     * Note that the end-to-end account is now stored in the
+     * crypto store rather than here: this remains here so
+     * old sessions can be migrated out of the session store.
      * @return {?string} Base64 encoded account.
      */
-    getEndToEndAccount: function() {
+    getEndToEndAccount: function () {
         return this.store.getItem(KEY_END_TO_END_ACCOUNT);
     },
 
     /**
-     * Store a flag indicating that we have announced the new device.
+     * Retrieves the known devices for all users.
+     * @return {object} A map from user ID to map of device ID to keys for the device.
      */
-    setDeviceAnnounced: function() {
-        this.store.setItem(KEY_END_TO_END_ANNOUNCED, "true");
+    getAllEndToEndDevices: function () {
+        const prefix = keyEndToEndDevicesForUser('');
+        const devices = {};
+        for (let i = 0; i < this.store.length; ++i) {
+            const key = this.store.key(i);
+            const userId = key.substr(prefix.length);
+            if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key);
+        }
+        return devices;
     },
 
-    /**
-     * Check if the "device announced" flag is set
-     *
-     * @return {boolean} true if the "device announced" flag has been set.
-     */
-    getDeviceAnnounced: function() {
-        return this.store.getItem(KEY_END_TO_END_ANNOUNCED) == "true";
+    getEndToEndDeviceTrackingStatus: function () {
+        return getJsonItem(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS);
     },
 
     /**
-     * Stores the known devices for a user.
-     * @param {string} userId The user's ID.
-     * @param {object} devices A map from device ID to keys for the device.
+     * Get the sync token corresponding to the device list.
+     *
+     * @return {String?} token
      */
-    storeEndToEndDevicesForUser: function(userId, devices) {
-        setJsonItem(this.store, keyEndToEndDevicesForUser(userId), devices);
+    getEndToEndDeviceSyncToken: function () {
+        return getJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN);
     },
 
     /**
-     * Retrieves the known devices for a user.
-     * @param {string} userId The user's ID.
-     * @return {object} A map from device ID to keys for the device.
+     * Removes all end to end device data from the store
      */
-    getEndToEndDevicesForUser: function(userId)  {
-        return getJsonItem(this.store, keyEndToEndDevicesForUser(userId));
-    },
-
-    /**
-     * Store a session between the logged-in user and another device
-     * @param {string} deviceKey The public key of the other device.
-     * @param {string} sessionId The ID for this end-to-end session.
-     * @param {string} session Base64 encoded end-to-end session.
-     */
-    storeEndToEndSession: function(deviceKey, sessionId, session) {
-        var sessions = this.getEndToEndSessions(deviceKey) || {};
-        sessions[sessionId] = session;
-        setJsonItem(
-            this.store, keyEndToEndSessions(deviceKey), sessions
-        );
+    removeEndToEndDeviceData: function () {
+        removeByPrefix(this.store, keyEndToEndDevicesForUser(''));
+        removeByPrefix(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS);
+        removeByPrefix(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN);
     },
 
     /**
      * Retrieve the end-to-end sessions between the logged-in user and another
      * device.
      * @param {string} deviceKey The public key of the other device.
      * @return {object} A map from sessionId to Base64 end-to-end session.
      */
-    getEndToEndSessions: function(deviceKey) {
+    getEndToEndSessions: function (deviceKey) {
         return getJsonItem(this.store, keyEndToEndSessions(deviceKey));
     },
 
-    getEndToEndInboundGroupSession: function(senderKey, sessionId) {
-        var key = keyEndToEndInboundGroupSession(senderKey, sessionId);
-        return this.store.getItem(key);
+    /**
+     * Retrieve all end-to-end sessions between the logged-in user and other
+     * devices.
+     * @return {object} A map of {deviceKey -> {sessionId -> session pickle}}
+     */
+    getAllEndToEndSessions: function () {
+        const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions(''));
+        const results = {};
+        for (const k of deviceKeys) {
+            const unprefixedKey = k.substr(keyEndToEndSessions('').length);
+            results[unprefixedKey] = getJsonItem(this.store, k);
+        }
+        return results;
     },
 
-    storeEndToEndInboundGroupSession: function(senderKey, sessionId, pickledSession) {
-        var key = keyEndToEndInboundGroupSession(senderKey, sessionId);
-        return this.store.setItem(key, pickledSession);
+    /**
+     * Remove all end-to-end sessions from the store
+     * This is used after migrating sessions awat from the sessions store.
+     */
+    removeAllEndToEndSessions: function () {
+        removeByPrefix(this.store, keyEndToEndSessions(''));
     },
 
     /**
-     * Store the end-to-end state for a room.
-     * @param {string} roomId The room's ID.
-     * @param {object} roomInfo The end-to-end info for the room.
+     * Retrieve a list of all known inbound group sessions
+     *
+     * @return {{senderKey: string, sessionId: string}}
      */
-    storeEndToEndRoom: function(roomId, roomInfo) {
-        setJsonItem(this.store, keyEndToEndRoom(roomId), roomInfo);
+    getAllEndToEndInboundGroupSessionKeys: function () {
+        const prefix = E2E_PREFIX + 'inboundgroupsessions/';
+        const result = [];
+        for (let i = 0; i < this.store.length; i++) {
+            const key = this.store.key(i);
+            if (!key.startsWith(prefix)) {
+                continue;
+            }
+            // we can't use split, as the components we are trying to split out
+            // might themselves contain '/' characters. We rely on the
+            // senderKey being a (32-byte) curve25519 key, base64-encoded
+            // (hence 43 characters long).
+
+            result.push({
+                senderKey: key.substr(prefix.length, 43),
+                sessionId: key.substr(prefix.length + 44)
+            });
+        }
+        return result;
+    },
+
+    getEndToEndInboundGroupSession: function (senderKey, sessionId) {
+        const key = keyEndToEndInboundGroupSession(senderKey, sessionId);
+        return this.store.getItem(key);
+    },
+
+    removeAllEndToEndInboundGroupSessions: function () {
+        removeByPrefix(this.store, E2E_PREFIX + 'inboundgroupsessions/');
     },
 
     /**
-     * Get the end-to-end state for a room
-     * @param {string} roomId The room's ID.
-     * @return {object} The end-to-end info for the room.
+     * Get the end-to-end state for all rooms
+     * @return {object} roomId -> object with the end-to-end info for the room.
      */
-    getEndToEndRoom: function(roomId) {
-        return getJsonItem(this.store, keyEndToEndRoom(roomId));
+    getAllEndToEndRooms: function () {
+        const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom(''));
+        const results = {};
+        for (const k of roomKeys) {
+            const unprefixedKey = k.substr(keyEndToEndRoom('').length);
+            results[unprefixedKey] = getJsonItem(this.store, k);
+        }
+        return results;
+    },
+
+    removeAllEndToEndRooms: function () {
+        removeByPrefix(this.store, keyEndToEndRoom(''));
+    },
+
+    setLocalTrustedBackupPubKey: function (pubkey) {
+        this.store.setItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY, pubkey);
+    },
+
+    // XXX: This store is deprecated really, but added this as a temporary
+    // thing until cross-signing lands.
+    getLocalTrustedBackupPubKey: function () {
+        return this.store.getItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY);
     }
 };
 
-var KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
-var KEY_END_TO_END_ANNOUNCED = E2E_PREFIX + "announced";
+const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
+const KEY_END_TO_END_DEVICE_SYNC_TOKEN = E2E_PREFIX + "device_sync_token";
+const KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS = E2E_PREFIX + "device_tracking";
+const KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY = E2E_PREFIX + "trusted_backup_pubkey";
 
 function keyEndToEndDevicesForUser(userId) {
     return E2E_PREFIX + "devices/" + userId;
 }
 
 function keyEndToEndSessions(deviceKey) {
     return E2E_PREFIX + "sessions/" + deviceKey;
 }
@@ -165,29 +220,46 @@ function keyEndToEndInboundGroupSession(
 }
 
 function keyEndToEndRoom(roomId) {
     return E2E_PREFIX + "rooms/" + roomId;
 }
 
 function getJsonItem(store, key) {
     try {
+        // if the key is absent, store.getItem() returns null, and
+        // JSON.parse(null) === null, so this returns null.
         return JSON.parse(store.getItem(key));
-    }
-    catch (e) {
+    } catch (e) {
         debuglog("Failed to get key %s: %s", key, e);
         debuglog(e.stack);
     }
     return null;
 }
 
-function setJsonItem(store, key, val) {
-    store.setItem(key, JSON.stringify(val));
+function getKeysWithPrefix(store, prefix) {
+    const results = [];
+    for (let i = 0; i < store.length; ++i) {
+        const key = store.key(i);
+        if (key.startsWith(prefix)) results.push(key);
+    }
+    return results;
+}
+
+function removeByPrefix(store, prefix) {
+    const toRemove = [];
+    for (let i = 0; i < store.length; ++i) {
+        const key = store.key(i);
+        if (key.startsWith(prefix)) toRemove.push(key);
+    }
+    for (const key of toRemove) {
+        store.removeItem(key);
+    }
 }
 
 function debuglog() {
     if (DEBUG) {
-        console.log.apply(console, arguments);
+        _logger2.default.log(...arguments);
     }
 }
 
 /** */
-module.exports = WebStorageSessionStore;
+module.exports = WebStorageSessionStore;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/store/stub.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/store/stub.js
@@ -1,191 +1,290 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
 /**
  * This is an internal module.
  * @module store/stub
  */
 
 /**
  * Construct a stub store. This does no-ops on most store methods.
  * @constructor
  */
 function StubStore() {
-    this.fromToken = null;
+  this.fromToken = null;
 }
 
 StubStore.prototype = {
 
-    /**
-     * Get the sync token.
-     * @return {string}
-     */
-    getSyncToken: function() {
-        return this.fromToken;
-    },
+  /** @return {Promise<bool>} whether or not the database was newly created in this session. */
+  isNewlyCreated: function () {
+    return _bluebird2.default.resolve(true);
+  },
 
-    /**
-     * Set the sync token.
-     * @param {string} token
-     */
-    setSyncToken: function(token) {
-        this.fromToken = token;
-    },
+  /**
+   * Get the sync token.
+   * @return {string}
+   */
+  getSyncToken: function () {
+    return this.fromToken;
+  },
+
+  /**
+   * Set the sync token.
+   * @param {string} token
+   */
+  setSyncToken: function (token) {
+    this.fromToken = token;
+  },
 
-    /**
-     * No-op.
-     * @param {Room} room
-     */
-    storeRoom: function(room) {
-    },
+  /**
+   * No-op.
+   * @param {Group} group
+   */
+  storeGroup: function (group) {},
 
-    /**
-     * No-op.
-     * @param {string} roomId
-     * @return {null}
-     */
-    getRoom: function(roomId) {
-        return null;
-    },
+  /**
+   * No-op.
+   * @param {string} groupId
+   * @return {null}
+   */
+  getGroup: function (groupId) {
+    return null;
+  },
+
+  /**
+   * No-op.
+   * @return {Array} An empty array.
+   */
+  getGroups: function () {
+    return [];
+  },
+
+  /**
+   * No-op.
+   * @param {Room} room
+   */
+  storeRoom: function (room) {},
 
-    /**
-     * No-op.
-     * @return {Array} An empty array.
-     */
-    getRooms: function() {
-        return [];
-    },
+  /**
+   * No-op.
+   * @param {string} roomId
+   * @return {null}
+   */
+  getRoom: function (roomId) {
+    return null;
+  },
 
-    /**
-     * Permanently delete a room.
-     * @param {string} roomId
-     */
-    removeRoom: function(roomId) {
-        return;
-    },
+  /**
+   * No-op.
+   * @return {Array} An empty array.
+   */
+  getRooms: function () {
+    return [];
+  },
+
+  /**
+   * Permanently delete a room.
+   * @param {string} roomId
+   */
+  removeRoom: function (roomId) {
+    return;
+  },
+
+  /**
+   * No-op.
+   * @return {Array} An empty array.
+   */
+  getRoomSummaries: function () {
+    return [];
+  },
 
-    /**
-     * No-op.
-     * @return {Array} An empty array.
-     */
-    getRoomSummaries: function() {
-        return [];
-    },
+  /**
+   * No-op.
+   * @param {User} user
+   */
+  storeUser: function (user) {},
+
+  /**
+   * No-op.
+   * @param {string} userId
+   * @return {null}
+   */
+  getUser: function (userId) {
+    return null;
+  },
 
-    /**
-     * No-op.
-     * @param {User} user
-     */
-    storeUser: function(user) {
-    },
+  /**
+   * No-op.
+   * @return {User[]}
+   */
+  getUsers: function () {
+    return [];
+  },
 
-    /**
-     * No-op.
-     * @param {string} userId
-     * @return {null}
-     */
-    getUser: function(userId) {
-        return null;
-    },
+  /**
+   * No-op.
+   * @param {Room} room
+   * @param {integer} limit
+   * @return {Array}
+   */
+  scrollback: function (room, limit) {
+    return [];
+  },
 
-    /**
-     * No-op.
-     * @return {User[]}
-     */
-    getUsers: function() {
-        return [];
-    },
+  /**
+   * Store events for a room.
+   * @param {Room} room The room to store events for.
+   * @param {Array<MatrixEvent>} events The events to store.
+   * @param {string} token The token associated with these events.
+   * @param {boolean} toStart True if these are paginated results.
+   */
+  storeEvents: function (room, events, token, toStart) {},
+
+  /**
+   * Store a filter.
+   * @param {Filter} filter
+   */
+  storeFilter: function (filter) {},
+
+  /**
+   * Retrieve a filter.
+   * @param {string} userId
+   * @param {string} filterId
+   * @return {?Filter} A filter or null.
+   */
+  getFilter: function (userId, filterId) {
+    return null;
+  },
 
-    /**
-     * No-op.
-     * @param {Room} room
-     * @param {integer} limit
-     * @return {Array}
-     */
-    scrollback: function(room, limit) {
-        return [];
-    },
+  /**
+   * Retrieve a filter ID with the given name.
+   * @param {string} filterName The filter name.
+   * @return {?string} The filter ID or null.
+   */
+  getFilterIdByName: function (filterName) {
+    return null;
+  },
+
+  /**
+   * Set a filter name to ID mapping.
+   * @param {string} filterName
+   * @param {string} filterId
+   */
+  setFilterIdByName: function (filterName, filterId) {},
 
-    /**
-     * Store events for a room.
-     * @param {Room} room The room to store events for.
-     * @param {Array<MatrixEvent>} events The events to store.
-     * @param {string} token The token associated with these events.
-     * @param {boolean} toStart True if these are paginated results.
-     */
-    storeEvents: function(room, events, token, toStart) {
-    },
+  /**
+   * Store user-scoped account data events
+   * @param {Array<MatrixEvent>} events The events to store.
+   */
+  storeAccountDataEvents: function (events) {},
 
-    /**
-     * Store a filter.
-     * @param {Filter} filter
-     */
-    storeFilter: function(filter) {
-    },
+  /**
+   * Get account data event by event type
+   * @param {string} eventType The event type being queried
+   */
+  getAccountData: function (eventType) {},
+
+  /**
+   * setSyncData does nothing as there is no backing data store.
+   *
+   * @param {Object} syncData The sync data
+   * @return {Promise} An immediately resolved promise.
+   */
+  setSyncData: function (syncData) {
+    return _bluebird2.default.resolve();
+  },
 
-    /**
-     * Retrieve a filter.
-     * @param {string} userId
-     * @param {string} filterId
-     * @return {?Filter} A filter or null.
-     */
-    getFilter: function(userId, filterId) {
-        return null;
-    },
+  /**
+   * We never want to save becase we have nothing to save to.
+   *
+   * @return {boolean} If the store wants to save
+   */
+  wantsSave: function () {
+    return false;
+  },
+
+  /**
+   * Save does nothing as there is no backing data store.
+   */
+  save: function () {},
 
-    /**
-     * Retrieve a filter ID with the given name.
-     * @param {string} filterName The filter name.
-     * @return {?string} The filter ID or null.
-     */
-    getFilterIdByName: function(filterName) {
-        return null;
-    },
+  /**
+   * Startup does nothing.
+   * @return {Promise} An immediately resolved promise.
+   */
+  startup: function () {
+    return _bluebird2.default.resolve();
+  },
+
+  /**
+   * @return {Promise} Resolves with a sync response to restore the
+   * client state to where it was at the last save, or null if there
+   * is no saved sync data.
+   */
+  getSavedSync: function () {
+    return _bluebird2.default.resolve(null);
+  },
 
-    /**
-     * Set a filter name to ID mapping.
-     * @param {string} filterName
-     * @param {string} filterId
-     */
-    setFilterIdByName: function(filterName, filterId) {
+  /**
+   * @return {Promise} If there is a saved sync, the nextBatch token
+   * for this sync, otherwise null.
+   */
+  getSavedSyncToken: function () {
+    return _bluebird2.default.resolve(null);
+  },
 
-    },
-
-    /**
-     * Store user-scoped account data events
-     * @param {Array<MatrixEvent>} events The events to store.
-     */
-    storeAccountDataEvents: function(events) {
+  /**
+   * Delete all data from this store. Does nothing since this store
+   * doesn't store anything.
+   * @return {Promise} An immediately resolved promise.
+   */
+  deleteAllData: function () {
+    return _bluebird2.default.resolve();
+  },
 
-    },
+  getOutOfBandMembers: function () {
+    return _bluebird2.default.resolve(null);
+  },
 
-    /**
-     * Get account data event by event type
-     * @param {string} eventType The event type being queried
-     */
-    getAccountData: function(eventType) {
+  setOutOfBandMembers: function () {
+    return _bluebird2.default.resolve();
+  },
 
-    },
+  clearOutOfBandMembers: function () {
+    return _bluebird2.default.resolve();
+  },
 
-    // TODO
-    //setMaxHistoryPerRoom: function(maxHistory) {},
+  getClientOptions: function () {
+    return _bluebird2.default.resolve();
+  },
 
-    // TODO
-    //reapOldMessages: function() {},
+  storeClientOptions: function () {
+    return _bluebird2.default.resolve();
+  }
 };
 
 /** Stub Store class. */
-module.exports = StubStore;
+module.exports = StubStore;
\ No newline at end of file
deleted file mode 100644
--- a/chat/protocols/matrix/lib/matrix-sdk/store/webstorage.js
+++ /dev/null
@@ -1,686 +0,0 @@
-/*
-Copyright 2015, 2016 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-"use strict";
-/**
- * This is an internal module. Implementation details:
- * <pre>
- * Room data is stored as follows:
- *   room_$ROOMID_timeline_$INDEX : [ Event, Event, Event ]
- *   room_$ROOMID_state : {
- *                          pagination_token: <oldState.paginationToken>,
- *                          events: {
- *                            <event_type>: { <state_key> : {JSON} }
- *                          }
- *                        }
- * User data is stored as follows:
- *   user_$USERID : User
- * Sync token:
- *   sync_token : $TOKEN
- *
- * Room Retrieval
- * --------------
- * Retrieving a room requires the $ROOMID which then pulls out the current state
- * from room_$ROOMID_state. A defined starting batch of timeline events are then
- * extracted from the highest numbered $INDEX for room_$ROOMID_timeline_$INDEX
- * (more indices as required). The $INDEX may be negative. These are
- * added to the timeline in the same way as /initialSync (old state will diverge).
- * If there exists a room_$ROOMID_timeline_live key, then a timeline sync should
- * be performed before retrieving.
- *
- * Retrieval of earlier messages
- * -----------------------------
- * The earliest event the Room instance knows about is E. Retrieving earlier
- * messages requires a Room which has a storageToken defined.
- * This token maps to the index I where the Room is at. Events are then retrieved from
- * room_$ROOMID_timeline_{I} and elements before E are extracted. If the limit
- * demands more events, I-1 is retrieved, up until I=min $INDEX where it gives
- * less than the limit. Index may go negative if you have paginated in the past.
- *
- * Full Insertion
- * --------------
- * Storing a room requires the timeline and state keys for $ROOMID to
- * be blown away and completely replaced, which is computationally expensive.
- * Room.timeline is batched according to the given batch size B. These batches
- * are then inserted into storage as room_$ROOMID_timeline_$INDEX. Finally,
- * the current room state is persisted to room_$ROOMID_state.
- *
- * Incremental Insertion
- * ---------------------
- * As events arrive, the store can quickly persist these new events. This
- * involves pushing the events to room_$ROOMID_timeline_live. If the
- * current room state has been modified by the new event, then
- * room_$ROOMID_state should be updated in addition to the timeline.
- *
- * Timeline sync
- * -------------
- * Retrieval of events from the timeline depends on the proper batching of
- * events. This is computationally expensive to perform on every new event, so
- * is deferred by inserting live events to room_$ROOMID_timeline_live. A
- * timeline sync reconciles timeline_live and timeline_$INDEX. This involves
- * retrieving _live and the highest numbered $INDEX batch. If the batch is < B,
- * the earliest entries from _live are inserted into the $INDEX until the
- * batch == B. Then, the remaining entries in _live are batched to $INDEX+1,
- * $INDEX+2, and so on. The easiest way to visualise this is that the timeline
- * goes from old to new, left to right:
- *          -2         -1         0         1
- * <--OLD---------------------------------------NEW-->
- *        [a,b,c]    [d,e,f]   [g,h,i]   [j,k,l]
- *
- * Purging
- * -------
- * Events from the timeline can be purged by removing the lowest
- * timeline_$INDEX in the store.
- *
- * Example
- * -------
- * A room with room_id !foo:bar has 9 messages (M1->9 where 9=newest) with a
- * batch size of 4. The very first time, there is no entry for !foo:bar until
- * storeRoom() is called, which results in the keys: [Full Insert]
- *   room_!foo:bar_timeline_0 : [M1, M2, M3, M4]
- *   room_!foo:bar_timeline_1 : [M5, M6, M7, M8]
- *   room_!foo:bar_timeline_2 : [M9]
- *   room_!foo:bar_state: { ... }
- *
- * 5 new messages (N1-5, 5=newest) arrive and are then added: [Incremental Insert]
- *   room_!foo:bar_timeline_live: [N1]
- *   room_!foo:bar_timeline_live: [N1, N2]
- *   room_!foo:bar_timeline_live: [N1, N2, N3]
- *   room_!foo:bar_timeline_live: [N1, N2, N3, N4]
- *   room_!foo:bar_timeline_live: [N1, N2, N3, N4, N5]
- *
- * App is shutdown. Restarts. The timeline is synced [Timeline Sync]
- *   room_!foo:bar_timeline_2 : [M9, N1, N2, N3]
- *   room_!foo:bar_timeline_3 : [N4, N5]
- *   room_!foo:bar_timeline_live: []
- *
- * And the room is retrieved with 8 messages: [Room Retrieval]
- *   Room.timeline: [M7, M8, M9, N1, N2, N3, N4, N5]
- *   Room.storageToken: => early_index = 1 because that's where M7 is.
- *
- * 3 earlier messages are requested: [Earlier retrieval]
- *   Use storageToken to find batch index 1. Scan batch for earliest event ID.
- *   earliest event = M7
- *   events = room_!foo:bar_timeline_1 where event < M7 = [M5, M6]
- * Too few events, use next index (0) and get 1 more:
- *   events = room_!foo:bar_timeline_0 = [M1, M2, M3, M4] => [M4]
- * Return concatentation:
- *   [M4, M5, M6]
- *
- * Purge oldest events: [Purge]
- *   del room_!foo:bar_timeline_0
- * </pre>
- * @module store/webstorage
- */
-var DEBUG = false;  // set true to enable console logging.
-var utils = require("../utils");
-var Room = require("../models/room");
-var User = require("../models/user");
-var MatrixEvent = require("../models/event").MatrixEvent;
-
-/**
- * Construct a web storage store, capable of storing rooms and users.
- * @constructor
- * @param {WebStorage} webStore A web storage implementation, e.g.
- * 'window.localStorage' or 'window.sessionStorage' or a custom implementation.
- * @param {integer} batchSize The number of events to store per key/value (room
- * scoped). Use -1 to store all events for a room under one key/value.
- * @throws if the supplied 'store' does not meet the Storage interface of the
- * WebStorage API.
- */
-function WebStorageStore(webStore, batchSize) {
-    this.store = webStore;
-    this.batchSize = batchSize;
-    if (!utils.isFunction(webStore.getItem) || !utils.isFunction(webStore.setItem) ||
-            !utils.isFunction(webStore.removeItem) || !utils.isFunction(webStore.key)) {
-        throw new Error(
-            "Supplied webStore does not meet the WebStorage API interface"
-        );
-    }
-    if (!parseInt(webStore.length) && webStore.length !== 0) {
-        throw new Error(
-            "Supplied webStore does not meet the WebStorage API interface (length)"
-        );
-    }
-    // cached list of room_ids this is storing.
-    this._roomIds = [];
-    this._syncedWithStore = false;
-    // tokens used to remember which index the room instance is at.
-    this._tokens = [
-        // { earliestIndex: -4 }
-    ];
-}
-
-
-/**
- * Retrieve the token to stream from.
- * @return {string} The token or null.
- */
-WebStorageStore.prototype.getSyncToken = function() {
-    return this.store.getItem("sync_token");
-};
-
-/**
- * Set the token to stream from.
- * @param {string} token The token to stream from.
- */
-WebStorageStore.prototype.setSyncToken = function(token) {
-    this.store.setItem("sync_token", token);
-};
-
-/**
- * Store a room in web storage.
- * @param {Room} room
- */
-WebStorageStore.prototype.storeRoom = function(room) {
-    var serRoom = SerialisedRoom.fromRoom(room, this.batchSize);
-    persist(this.store, serRoom);
-    if (this._roomIds.indexOf(room.roomId) === -1) {
-        this._roomIds.push(room.roomId);
-    }
-};
-
-/**
- * Retrieve a room from web storage.
- * @param {string} roomId
- * @return {?Room}
- */
-WebStorageStore.prototype.getRoom = function(roomId) {
-    // probe if room exists; break early if not. Every room should have state.
-    if (!getItem(this.store, keyName(roomId, "state"))) {
-        debuglog("getRoom: No room with id %s found.", roomId);
-        return null;
-    }
-    var timelineKeys = getTimelineIndices(this.store, roomId);
-    if (timelineKeys.indexOf("live") !== -1) {
-        debuglog("getRoom: Live events found. Syncing timeline for %s", roomId);
-        this._syncTimeline(roomId, timelineKeys);
-    }
-    return loadRoom(this.store, roomId, this.batchSize, this._tokens);
-};
-
-/**
- * Get a list of all rooms from web storage.
- * @return {Array} An empty array.
- */
-WebStorageStore.prototype.getRooms = function() {
-    var rooms = [];
-    var i;
-    if (!this._syncedWithStore) {
-        // sync with the store to set this._roomIds correctly. We know there is
-        // exactly one 'state' key for each room, so we grab them.
-        this._roomIds = [];
-        for (i = 0; i < this.store.length; i++) {
-            if (this.store.key(i).indexOf("room_") === 0 &&
-                    this.store.key(i).indexOf("_state") !== -1) {
-                // grab the middle bit which is the room ID
-                var k = this.store.key(i);
-                this._roomIds.push(
-                    k.substring("room_".length, k.length - "_state".length)
-                );
-            }
-        }
-        this._syncedWithStore = true;
-    }
-    // call getRoom on each room_id
-    for (i = 0; i < this._roomIds.length; i++) {
-        var rm = this.getRoom(this._roomIds[i]);
-        if (rm) {
-            rooms.push(rm);
-        }
-    }
-    return rooms;
-};
-
-/**
- * Get a list of summaries from web storage.
- * @return {Array} An empty array.
- */
-WebStorageStore.prototype.getRoomSummaries = function() {
-    return [];
-};
-
-/**
- * Store a user in web storage.
- * @param {User} user
- */
-WebStorageStore.prototype.storeUser = function(user) {
-    // persist the events used to make the user, we can reconstruct on demand.
-    setItem(this.store, "user_" + user.userId, {
-        presence: user.events.presence ? user.events.presence.event : null
-    });
-};
-
-/**
- * Get a user from web storage.
- * @param {string} userId
- * @return {User}
- */
-WebStorageStore.prototype.getUser = function(userId) {
-    var userData = getItem(this.store, "user_" + userId);
-    if (!userData) {
-        return null;
-    }
-    var user = new User(userId);
-    if (userData.presence) {
-        user.setPresenceEvent(new MatrixEvent(userData.presence));
-    }
-    return user;
-};
-
-/**
- * Retrieve scrollback for this room. Automatically adds events to the timeline.
- * @param {Room} room The matrix room to add the events to the start of the timeline.
- * @param {integer} limit The max number of old events to retrieve.
- * @return {Array<Object>} An array of objects which will be at most 'limit'
- * length and at least 0. The objects are the raw event JSON. The last element
- * is the 'oldest' (for parity with homeserver scrollback APIs).
- */
-WebStorageStore.prototype.scrollback = function(room, limit) {
-    if (room.storageToken === undefined || room.storageToken >= this._tokens.length) {
-        return [];
-    }
-    // find the index of the earliest event in this room's timeline
-    var storeData = this._tokens[room.storageToken] || {};
-    var i;
-    var earliestIndex = storeData.earliestIndex;
-    var earliestEventId = room.timeline[0] ? room.timeline[0].getId() : null;
-    debuglog(
-        "scrollback in %s (timeline=%s msgs) i=%s, timeline[0].id=%s - req %s events",
-        room.roomId, room.timeline.length, earliestIndex, earliestEventId, limit
-    );
-    var batch = getItem(
-        this.store, keyName(room.roomId, "timeline", earliestIndex)
-    );
-    if (!batch) {
-        // bad room or already at start, either way we have nothing to give.
-        debuglog("No batch with index %s found.", earliestIndex);
-        return [];
-    }
-    // populate from this batch first
-    var scrollback = [];
-    var foundEventId = false;
-    for (i = batch.length - 1; i >= 0; i--) {
-        // go back and find the earliest event ID, THEN start adding entries.
-        // Make a MatrixEvent so we don't assume .event_id exists
-        // (e.g v2/v3 JSON may be different)
-        var matrixEvent = new MatrixEvent(batch[i]);
-        if (matrixEvent.getId() === earliestEventId) {
-            foundEventId = true;
-            debuglog(
-                "Found timeline[0] event at position %s in batch %s",
-                i, earliestIndex
-            );
-            continue;
-        }
-        if (!foundEventId) {
-            continue;
-        }
-        // add entry
-        debuglog("Add event at position %s in batch %s", i, earliestIndex);
-        scrollback.push(batch[i]);
-        if (scrollback.length === limit) {
-            break;
-        }
-    }
-    if (scrollback.length === limit) {
-        debuglog("Batch has enough events to satisfy request.");
-        return scrollback;
-    }
-    if (!foundEventId) {
-        // the earliest index batch didn't contain the event. In other words,
-        // this timeline is at a state we don't know, so bail.
-        debuglog(
-            "Failed to find event ID %s in batch %s", earliestEventId, earliestIndex
-        );
-        return [];
-    }
-
-    // get the requested earlier events from earlier batches
-    while (scrollback.length < limit) {
-        earliestIndex--;
-        batch = getItem(
-            this.store, keyName(room.roomId, "timeline", earliestIndex)
-        );
-        if (!batch) {
-            // no more events
-            debuglog("No batch found at index %s", earliestIndex);
-            break;
-        }
-        for (i = batch.length - 1; i >= 0; i--) {
-            debuglog("Add event at position %s in batch %s", i, earliestIndex);
-            scrollback.push(batch[i]);
-            if (scrollback.length === limit) {
-                break;
-            }
-        }
-    }
-    debuglog(
-        "Out of %s requested events, returning %s. New index=%s",
-        limit, scrollback.length, earliestIndex
-    );
-    room.addEventsToTimeline(utils.map(scrollback, function(e) {
-            return new MatrixEvent(e);
-    }), true, room.getLiveTimeline());
-
-    this._tokens[room.storageToken] = {
-        earliestIndex: earliestIndex
-    };
-    return scrollback;
-};
-
-/**
- * Store events for a room. The events have already been added to the timeline.
- * @param {Room} room The room to store events for.
- * @param {Array<MatrixEvent>} events The events to store.
- * @param {string} token The token associated with these events.
- * @param {boolean} toStart True if these are paginated results. The last element
- * is the 'oldest' (for parity with homeserver scrollback APIs).
- */
-WebStorageStore.prototype.storeEvents = function(room, events, token, toStart) {
-    if (toStart) {
-        // add paginated events to lowest batch indexes (can go -ve)
-        var lowIndex = getIndexExtremity(
-            getTimelineIndices(this.store, room.roomId), true
-        );
-        var i, key, batch;
-        for (i = 0; i < events.length; i++) { // loop events to be stored
-            key = keyName(room.roomId, "timeline", lowIndex);
-            batch = getItem(this.store, key) || [];
-            while (batch.length < this.batchSize && i < events.length) {
-                batch.unshift(events[i].event);
-                i++; // increment to insert next event into this batch
-            }
-            i--; // decrement to avoid skipping one (for loop ++s)
-            setItem(this.store, key, batch);
-            lowIndex--; // decrement index to get a new batch.
-        }
-    }
-    else {
-        // dump as live events
-        var liveEvents = getItem(
-            this.store, keyName(room.roomId, "timeline", "live")
-        ) || [];
-        debuglog(
-            "Adding %s events to %s live list (which has %s already)",
-            events.length, room.roomId, liveEvents.length
-        );
-        var updateState = false;
-        liveEvents = liveEvents.concat(utils.map(events, function(me) {
-            // cheeky check to avoid looping twice
-            if (me.isState()) {
-                updateState = true;
-            }
-            return me.event;
-        }));
-        setItem(
-            this.store, keyName(room.roomId, "timeline", "live"), liveEvents
-        );
-        if (updateState) {
-            debuglog("Storing state for %s as new events updated state", room.roomId);
-            // use 0 batch size; we don't care about batching right now.
-            var serRoom = SerialisedRoom.fromRoom(room, 0);
-            setItem(this.store, keyName(serRoom.roomId, "state"), serRoom.state);
-        }
-    }
-};
-
-/**
- * Sync the 'live' timeline, batching live events according to 'batchSize'.
- * @param {string} roomId The room to sync the timeline.
- * @param {Array<String>} timelineIndices Optional. The indices in the timeline
- * if known already.
- */
-WebStorageStore.prototype._syncTimeline = function(roomId, timelineIndices) {
-    timelineIndices = timelineIndices || getTimelineIndices(this.store, roomId);
-    var liveEvents = getItem(this.store, keyName(roomId, "timeline", "live")) || [];
-
-    // get the highest numbered $INDEX batch
-    var highestIndex = getIndexExtremity(timelineIndices);
-    var hiKey = keyName(roomId, "timeline", highestIndex);
-    var hiBatch = getItem(this.store, hiKey) || [];
-    // fill up the existing batch first.
-    while (hiBatch.length < this.batchSize && liveEvents.length > 0) {
-        hiBatch.push(liveEvents.shift());
-    }
-    setItem(this.store, hiKey, hiBatch);
-
-    // start adding new batches as required
-    var batch = [];
-    while (liveEvents.length > 0) {
-        batch.push(liveEvents.shift());
-        if (batch.length === this.batchSize || liveEvents.length === 0) {
-            // persist the full batch and make another
-            highestIndex++;
-            hiKey = keyName(roomId, "timeline", highestIndex);
-            setItem(this.store, hiKey, batch);
-            batch = [];
-        }
-    }
-    // reset live array
-    setItem(this.store, keyName(roomId, "timeline", "live"), []);
-};
-
-
-/**
- * Store a filter.
- * @param {Filter} filter
- */
-WebStorageStore.prototype.storeFilter = function(filter) {
-};
-
-/**
- * Retrieve a filter.
- * @param {string} userId
- * @param {string} filterId
- * @return {?Filter} A filter or null.
- */
-WebStorageStore.prototype.getFilter = function(userId, filterId) {
-    return null;
-};
-
-function SerialisedRoom(roomId) {
-    this.state = {
-        events: {}
-    };
-    this.timeline = {
-        // $INDEX: []
-    };
-    this.roomId = roomId;
-}
-
-/**
- * Convert a Room instance into a SerialisedRoom instance which can be stored
- * in the key value store.
- * @param {Room} room The matrix room to convert
- * @param {integer} batchSize The number of events per timeline batch
- * @return {SerialisedRoom} A serialised room representation of 'room'.
- */
-SerialisedRoom.fromRoom = function(room, batchSize) {
-    var self = new SerialisedRoom(room.roomId);
-    var index;
-    self.state.pagination_token = room.oldState.paginationToken;
-    // [room_$ROOMID_state] downcast to POJO from MatrixEvent
-    utils.forEach(utils.keys(room.currentState.events), function(eventType) {
-        utils.forEach(utils.keys(room.currentState.events[eventType]), function(skey) {
-            if (!self.state.events[eventType]) {
-                self.state.events[eventType] = {};
-            }
-            self.state.events[eventType][skey] = (
-                room.currentState.events[eventType][skey].event
-            );
-        });
-    });
-
-    // [room_$ROOMID_timeline_$INDEX]
-    if (batchSize > 0) {
-        index = 0;
-        while (index * batchSize < room.timeline.length) {
-            self.timeline[index] = room.timeline.slice(
-                index * batchSize, (index + 1) * batchSize
-            );
-            self.timeline[index] = utils.map(self.timeline[index], function(me) {
-                // use POJO not MatrixEvent
-                return me.event;
-            });
-            index++;
-        }
-    }
-    else { // don't batch
-        self.timeline[0] = utils.map(room.timeline, function(matrixEvent) {
-            return matrixEvent.event;
-        });
-    }
-    return self;
-};
-
-function loadRoom(store, roomId, numEvents, tokenArray) {
-    var room = new Room(roomId, {
-        storageToken: tokenArray.length
-    });
-
-    // populate state (flatten nested struct to event array)
-    var currentStateMap = getItem(store, keyName(roomId, "state"));
-    var stateEvents = [];
-    utils.forEach(utils.keys(currentStateMap.events), function(eventType) {
-        utils.forEach(utils.keys(currentStateMap.events[eventType]), function(skey) {
-            stateEvents.push(currentStateMap.events[eventType][skey]);
-        });
-    });
-    // TODO: Fix logic dupe with MatrixClient._processRoomEvents
-    var oldStateEvents = utils.map(
-        utils.deepCopy(stateEvents), function(e) {
-            return new MatrixEvent(e);
-        }
-    );
-    var currentStateEvents = utils.map(stateEvents, function(e) {
-            return new MatrixEvent(e);
-        }
-    );
-    room.oldState.setStateEvents(oldStateEvents);
-    room.currentState.setStateEvents(currentStateEvents);
-
-    // add most recent numEvents
-    var recentEvents = [];
-    var index = getIndexExtremity(getTimelineIndices(store, roomId));
-    var eventIndex = index;
-    var i, key, batch;
-    while (recentEvents.length < numEvents) {
-        key = keyName(roomId, "timeline", index);
-        batch = getItem(store, key) || [];
-        if (batch.length === 0) {
-            // nothing left in the store.
-            break;
-        }
-        for (i = batch.length - 1; i >= 0; i--) {
-            recentEvents.unshift(new MatrixEvent(batch[i]));
-            if (recentEvents.length === numEvents) {
-                eventIndex = index;
-                break;
-            }
-        }
-        index--;
-    }
-    // add events backwards to diverge old state correctly.
-    room.addEventsToTimeline(recentEvents.reverse(), true, room.getLiveTimeline());
-    room.oldState.paginationToken = currentStateMap.pagination_token;
-    // set the token data to let us know which index this room instance is at
-    // for scrollback.
-    tokenArray.push({
-        earliestIndex: eventIndex
-    });
-    return room;
-}
-
-function persist(store, serRoom) {
-    setItem(store, keyName(serRoom.roomId, "state"), serRoom.state);
-    utils.forEach(utils.keys(serRoom.timeline), function(index) {
-        setItem(store,
-            keyName(serRoom.roomId, "timeline", index),
-            serRoom.timeline[index]
-        );
-    });
-}
-
-function getTimelineIndices(store, roomId) {
-    var keys = [];
-    for (var i = 0; i < store.length; i++) {
-        if (store.key(i).indexOf(keyName(roomId, "timeline_")) !== -1) {
-            // e.g. room_$ROOMID_timeline_0  =>  0
-            keys.push(
-                store.key(i).replace(keyName(roomId, "timeline_"), "")
-            );
-        }
-    }
-    return keys;
-}
-
-function getIndexExtremity(timelineIndices, getLowest) {
-    var extremity, index;
-    for (var i = 0; i < timelineIndices.length; i++) {
-        index = parseInt(timelineIndices[i]);
-        if (!isNaN(index) && (
-                extremity === undefined ||
-                !getLowest && index > extremity ||
-                getLowest && index < extremity)) {
-            extremity = index;
-        }
-    }
-    return extremity;
-}
-
-function keyName(roomId, key, index) {
-    return "room_" + roomId + "_" + key + (
-        index === undefined ? "" : ("_" + index)
-    );
-}
-
-function getItem(store, key) {
-    try {
-        return JSON.parse(store.getItem(key));
-    }
-    catch (e) {
-        debuglog("Failed to get key %s: %s", key, e);
-        debuglog(e.stack);
-    }
-    return null;
-}
-
-function setItem(store, key, val) {
-    store.setItem(key, JSON.stringify(val));
-}
-
-function debuglog() {
-    if (DEBUG) {
-        console.log.apply(console, arguments);
-    }
-}
-
-/*
-function delRoomStruct(store, roomId) {
-    var prefix = "room_" + roomId;
-    var keysToRemove = [];
-    for (var i = 0; i < store.length; i++) {
-        if (store.key(i).indexOf(prefix) !== -1) {
-            keysToRemove.push(store.key(i));
-        }
-    }
-    utils.forEach(keysToRemove, function(key) {
-        store.removeItem(key);
-    });
-} */
-
-/** Web Storage Store class. */
-module.exports = WebStorageStore;
new file mode 100644
--- /dev/null
+++ b/chat/protocols/matrix/lib/matrix-sdk/sync-accumulator.js
@@ -0,0 +1,552 @@
+"use strict";
+
+var _utils = require("./utils");
+
+var _utils2 = _interopRequireDefault(_utils);
+
+var _logger = require("../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+/**
+ * The purpose of this class is to accumulate /sync responses such that a
+ * complete "initial" JSON response can be returned which accurately represents
+ * the sum total of the /sync responses accumulated to date. It only handles
+ * room data: that is, everything under the "rooms" top-level key.
+ *
+ * This class is used when persisting room data so a complete /sync response can
+ * be loaded from disk and incremental syncs can be performed on the server,
+ * rather than asking the server to do an initial sync on startup.
+ */
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link SyncAccumulator} for the public class.
+ * @module sync-accumulator
+ */
+
+class SyncAccumulator {
+    /**
+     * @param {Object} opts
+     * @param {Number=} opts.maxTimelineEntries The ideal maximum number of
+     * timeline entries to keep in the sync response. This is best-effort, as
+     * clients do not always have a back-pagination token for each event, so
+     * it's possible there may be slightly *less* than this value. There will
+     * never be more. This cannot be 0 or else it makes it impossible to scroll
+     * back in a room. Default: 50.
+     */
+    constructor(opts) {
+        opts = opts || {};
+        opts.maxTimelineEntries = opts.maxTimelineEntries || 50;
+        this.opts = opts;
+        this.accountData = {
+            //$event_type: Object
+        };
+        this.inviteRooms = {
+            //$roomId: { ... sync 'invite' json data ... }
+        };
+        this.joinRooms = {
+            //$roomId: {
+            //    _currentState: { $event_type: { $state_key: json } },
+            //    _timeline: [
+            //       { event: $event, token: null|token },
+            //       { event: $event, token: null|token },
+            //       { event: $event, token: null|token },
+            //       ...
+            //    ],
+            //    _summary: {
+            //       m.heroes: [ $user_id ],
+            //       m.joined_member_count: $count,
+            //       m.invited_member_count: $count
+            //    },
+            //    _accountData: { $event_type: json },
+            //    _unreadNotifications: { ... unread_notifications JSON ... },
+            //    _readReceipts: { $user_id: { data: $json, eventId: $event_id }}
+            //}
+        };
+        // the /sync token which corresponds to the last time rooms were
+        // accumulated. We remember this so that any caller can obtain a
+        // coherent /sync response and know at what point they should be
+        // streaming from without losing events.
+        this.nextBatch = null;
+
+        // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } }
+        this.groups = {
+            invite: {},
+            join: {},
+            leave: {}
+        };
+    }
+
+    accumulate(syncResponse) {
+        this._accumulateRooms(syncResponse);
+        this._accumulateGroups(syncResponse);
+        this._accumulateAccountData(syncResponse);
+        this.nextBatch = syncResponse.next_batch;
+    }
+
+    _accumulateAccountData(syncResponse) {
+        if (!syncResponse.account_data || !syncResponse.account_data.events) {
+            return;
+        }
+        // Clobbers based on event type.
+        syncResponse.account_data.events.forEach(e => {
+            this.accountData[e.type] = e;
+        });
+    }
+
+    /**
+     * Accumulate incremental /sync room data.
+     * @param {Object} syncResponse the complete /sync JSON
+     */
+    _accumulateRooms(syncResponse) {
+        if (!syncResponse.rooms) {
+            return;
+        }
+        if (syncResponse.rooms.invite) {
+            Object.keys(syncResponse.rooms.invite).forEach(roomId => {
+                this._accumulateRoom(roomId, "invite", syncResponse.rooms.invite[roomId]);
+            });
+        }
+        if (syncResponse.rooms.join) {
+            Object.keys(syncResponse.rooms.join).forEach(roomId => {
+                this._accumulateRoom(roomId, "join", syncResponse.rooms.join[roomId]);
+            });
+        }
+        if (syncResponse.rooms.leave) {
+            Object.keys(syncResponse.rooms.leave).forEach(roomId => {
+                this._accumulateRoom(roomId, "leave", syncResponse.rooms.leave[roomId]);
+            });
+        }
+    }
+
+    _accumulateRoom(roomId, category, data) {
+        // Valid /sync state transitions
+        //       +--------+ <======+            1: Accept an invite
+        //   +== | INVITE |        | (5)        2: Leave a room
+        //   |   +--------+ =====+ |            3: Join a public room previously
+        //   |(1)            (4) | |               left (handle as if new room)
+        //   V         (2)       V |            4: Reject an invite
+        // +------+ ========> +--------+         5: Invite to a room previously
+        // | JOIN |    (3)    | LEAVE* |            left (handle as if new room)
+        // +------+ <======== +--------+
+        //
+        // * equivalent to "no state"
+        switch (category) {
+            case "invite":
+                // (5)
+                this._accumulateInviteState(roomId, data);
+                break;
+            case "join":
+                if (this.inviteRooms[roomId]) {
+                    // (1)
+                    // was previously invite, now join. We expect /sync to give
+                    // the entire state and timeline on 'join', so delete previous
+                    // invite state
+                    delete this.inviteRooms[roomId];
+                }
+                // (3)
+                this._accumulateJoinState(roomId, data);
+                break;
+            case "leave":
+                if (this.inviteRooms[roomId]) {
+                    // (4)
+                    delete this.inviteRooms[roomId];
+                } else {
+                    // (2)
+                    delete this.joinRooms[roomId];
+                }
+                break;
+            default:
+                _logger2.default.error("Unknown cateogory: ", category);
+        }
+    }
+
+    _accumulateInviteState(roomId, data) {
+        if (!data.invite_state || !data.invite_state.events) {
+            // no new data
+            return;
+        }
+        if (!this.inviteRooms[roomId]) {
+            this.inviteRooms[roomId] = {
+                invite_state: data.invite_state
+            };
+            return;
+        }
+        // accumulate extra keys for invite->invite transitions
+        // clobber based on event type / state key
+        // We expect invite_state to be small, so just loop over the events
+        const currentData = this.inviteRooms[roomId];
+        data.invite_state.events.forEach(e => {
+            let hasAdded = false;
+            for (let i = 0; i < currentData.invite_state.events.length; i++) {
+                const current = currentData.invite_state.events[i];
+                if (current.type === e.type && current.state_key == e.state_key) {
+                    currentData.invite_state.events[i] = e; // update
+                    hasAdded = true;
+                }
+            }
+            if (!hasAdded) {
+                currentData.invite_state.events.push(e);
+            }
+        });
+    }
+
+    // Accumulate timeline and state events in a room.
+    _accumulateJoinState(roomId, data) {
+        // We expect this function to be called a lot (every /sync) so we want
+        // this to be fast. /sync stores events in an array but we often want
+        // to clobber based on type/state_key. Rather than convert arrays to
+        // maps all the time, just keep private maps which contain
+        // the actual current accumulated sync state, and array-ify it when
+        // getJSON() is called.
+
+        // State resolution:
+        // The 'state' key is the delta from the previous sync (or start of time
+        // if no token was supplied), to the START of the timeline. To obtain
+        // the current state, we need to "roll forward" state by reading the
+        // timeline. We want to store the current state so we can drop events
+        // out the end of the timeline based on opts.maxTimelineEntries.
+        //
+        //      'state'     'timeline'     current state
+        // |-------x<======================>x
+        //          T   I   M   E
+        //
+        // When getJSON() is called, we 'roll back' the current state by the
+        // number of entries in the timeline to work out what 'state' should be.
+
+        // Back-pagination:
+        // On an initial /sync, the server provides a back-pagination token for
+        // the start of the timeline. When /sync deltas come down, they also
+        // include back-pagination tokens for the start of the timeline. This
+        // means not all events in the timeline have back-pagination tokens, as
+        // it is only the ones at the START of the timeline which have them.
+        // In order for us to have a valid timeline (and back-pagination token
+        // to match), we need to make sure that when we remove old timeline
+        // events, that we roll forward to an event which has a back-pagination
+        // token. This means we can't keep a strict sliding-window based on
+        // opts.maxTimelineEntries, and we may have a few less. We should never
+        // have more though, provided that the /sync limit is less than or equal
+        // to opts.maxTimelineEntries.
+
+        if (!this.joinRooms[roomId]) {
+            // Create truly empty objects so event types of 'hasOwnProperty' and co
+            // don't cause this code to break.
+            this.joinRooms[roomId] = {
+                _currentState: Object.create(null),
+                _timeline: [],
+                _accountData: Object.create(null),
+                _unreadNotifications: {},
+                _summary: {},
+                _readReceipts: {}
+            };
+        }
+        const currentData = this.joinRooms[roomId];
+
+        if (data.account_data && data.account_data.events) {
+            // clobber based on type
+            data.account_data.events.forEach(e => {
+                currentData._accountData[e.type] = e;
+            });
+        }
+
+        // these probably clobber, spec is unclear.
+        if (data.unread_notifications) {
+            currentData._unreadNotifications = data.unread_notifications;
+        }
+        if (data.summary) {
+            const HEROES_KEY = "m.heroes";
+            const INVITED_COUNT_KEY = "m.invited_member_count";
+            const JOINED_COUNT_KEY = "m.joined_member_count";
+
+            const acc = currentData._summary;
+            const sum = data.summary;
+            acc[HEROES_KEY] = sum[HEROES_KEY] || acc[HEROES_KEY];
+            acc[JOINED_COUNT_KEY] = sum[JOINED_COUNT_KEY] || acc[JOINED_COUNT_KEY];
+            acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY];
+        }
+
+        if (data.ephemeral && data.ephemeral.events) {
+            data.ephemeral.events.forEach(e => {
+                // We purposefully do not persist m.typing events.
+                // Technically you could refresh a browser before the timer on a
+                // typing event is up, so it'll look like you aren't typing when
+                // you really still are. However, the alternative is worse. If
+                // we do persist typing events, it will look like people are
+                // typing forever until someone really does start typing (which
+                // will prompt Synapse to send down an actual m.typing event to
+                // clobber the one we persisted).
+                if (e.type !== "m.receipt" || !e.content) {
+                    // This means we'll drop unknown ephemeral events but that
+                    // seems okay.
+                    return;
+                }
+                // Handle m.receipt events. They clobber based on:
+                //   (user_id, receipt_type)
+                // but they are keyed in the event as:
+                //   content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
+                // so store them in the former so we can accumulate receipt deltas
+                // quickly and efficiently (we expect a lot of them). Fold the
+                // receipt type into the key name since we only have 1 at the
+                // moment (m.read) and nested JSON objects are slower and more
+                // of a hassle to work with. We'll inflate this back out when
+                // getJSON() is called.
+                Object.keys(e.content).forEach(eventId => {
+                    if (!e.content[eventId]["m.read"]) {
+                        return;
+                    }
+                    Object.keys(e.content[eventId]["m.read"]).forEach(userId => {
+                        // clobber on user ID
+                        currentData._readReceipts[userId] = {
+                            data: e.content[eventId]["m.read"][userId],
+                            eventId: eventId
+                        };
+                    });
+                });
+            });
+        }
+
+        // if we got a limited sync, we need to remove all timeline entries or else
+        // we will have gaps in the timeline.
+        if (data.timeline && data.timeline.limited) {
+            currentData._timeline = [];
+        }
+
+        // Work out the current state. The deltas need to be applied in the order:
+        // - existing state which didn't come down /sync.
+        // - State events under the 'state' key.
+        // - State events in the 'timeline'.
+        if (data.state && data.state.events) {
+            data.state.events.forEach(e => {
+                setState(currentData._currentState, e);
+            });
+        }
+        if (data.timeline && data.timeline.events) {
+            data.timeline.events.forEach((e, index) => {
+                // this nops if 'e' isn't a state event
+                setState(currentData._currentState, e);
+                // append the event to the timeline. The back-pagination token
+                // corresponds to the first event in the timeline
+                currentData._timeline.push({
+                    event: e,
+                    token: index === 0 ? data.timeline.prev_batch : null
+                });
+            });
+        }
+
+        // attempt to prune the timeline by jumping between events which have
+        // pagination tokens.
+        if (currentData._timeline.length > this.opts.maxTimelineEntries) {
+            const startIndex = currentData._timeline.length - this.opts.maxTimelineEntries;
+            for (let i = startIndex; i < currentData._timeline.length; i++) {
+                if (currentData._timeline[i].token) {
+                    // keep all events after this, including this one
+                    currentData._timeline = currentData._timeline.slice(i, currentData._timeline.length);
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * Accumulate incremental /sync group data.
+     * @param {Object} syncResponse the complete /sync JSON
+     */
+    _accumulateGroups(syncResponse) {
+        if (!syncResponse.groups) {
+            return;
+        }
+        if (syncResponse.groups.invite) {
+            Object.keys(syncResponse.groups.invite).forEach(groupId => {
+                this._accumulateGroup(groupId, "invite", syncResponse.groups.invite[groupId]);
+            });
+        }
+        if (syncResponse.groups.join) {
+            Object.keys(syncResponse.groups.join).forEach(groupId => {
+                this._accumulateGroup(groupId, "join", syncResponse.groups.join[groupId]);
+            });
+        }
+        if (syncResponse.groups.leave) {
+            Object.keys(syncResponse.groups.leave).forEach(groupId => {
+                this._accumulateGroup(groupId, "leave", syncResponse.groups.leave[groupId]);
+            });
+        }
+    }
+
+    _accumulateGroup(groupId, category, data) {
+        for (const cat of ['invite', 'join', 'leave']) {
+            delete this.groups[cat][groupId];
+        }
+        this.groups[category][groupId] = data;
+    }
+
+    /**
+     * Return everything under the 'rooms' key from a /sync response which
+     * represents all room data that should be stored. This should be paired
+     * with the sync token which represents the most recent /sync response
+     * provided to accumulate().
+     * @return {Object} An object with a "nextBatch", "roomsData" and "accountData"
+     * keys.
+     * The "nextBatch" key is a string which represents at what point in the
+     * /sync stream the accumulator reached. This token should be used when
+     * restarting a /sync stream at startup. Failure to do so can lead to missing
+     * events. The "roomsData" key is an Object which represents the entire
+     * /sync response from the 'rooms' key onwards. The "accountData" key is
+     * a list of raw events which represent global account data.
+     */
+    getJSON() {
+        const data = {
+            join: {},
+            invite: {},
+            // always empty. This is set by /sync when a room was previously
+            // in 'invite' or 'join'. On fresh startup, the client won't know
+            // about any previous room being in 'invite' or 'join' so we can
+            // just omit mentioning it at all, even if it has previously come
+            // down /sync.
+            // The notable exception is when a client is kicked or banned:
+            // we may want to hold onto that room so the client can clearly see
+            // why their room has disappeared. We don't persist it though because
+            // it is unclear *when* we can safely remove the room from the DB.
+            // Instead, we assume that if you're loading from the DB, you've
+            // refreshed the page, which means you've seen the kick/ban already.
+            leave: {}
+        };
+        Object.keys(this.inviteRooms).forEach(roomId => {
+            data.invite[roomId] = this.inviteRooms[roomId];
+        });
+        Object.keys(this.joinRooms).forEach(roomId => {
+            const roomData = this.joinRooms[roomId];
+            const roomJson = {
+                ephemeral: { events: [] },
+                account_data: { events: [] },
+                state: { events: [] },
+                timeline: {
+                    events: [],
+                    prev_batch: null
+                },
+                unread_notifications: roomData._unreadNotifications,
+                summary: roomData._summary
+            };
+            // Add account data
+            Object.keys(roomData._accountData).forEach(evType => {
+                roomJson.account_data.events.push(roomData._accountData[evType]);
+            });
+
+            // Add receipt data
+            const receiptEvent = {
+                type: "m.receipt",
+                room_id: roomId,
+                content: {
+                    // $event_id: { "m.read": { $user_id: $json } }
+                }
+            };
+            Object.keys(roomData._readReceipts).forEach(userId => {
+                const receiptData = roomData._readReceipts[userId];
+                if (!receiptEvent.content[receiptData.eventId]) {
+                    receiptEvent.content[receiptData.eventId] = {
+                        "m.read": {}
+                    };
+                }
+                receiptEvent.content[receiptData.eventId]["m.read"][userId] = receiptData.data;
+            });
+            // add only if we have some receipt data
+            if (Object.keys(receiptEvent.content).length > 0) {
+                roomJson.ephemeral.events.push(receiptEvent);
+            }
+
+            // Add timeline data
+            roomData._timeline.forEach(msgData => {
+                if (!roomJson.timeline.prev_batch) {
+                    // the first event we add to the timeline MUST match up to
+                    // the prev_batch token.
+                    if (!msgData.token) {
+                        return; // this shouldn't happen as we prune constantly.
+                    }
+                    roomJson.timeline.prev_batch = msgData.token;
+                }
+                roomJson.timeline.events.push(msgData.event);
+            });
+
+            // Add state data: roll back current state to the start of timeline,
+            // by "reverse clobbering" from the end of the timeline to the start.
+            // Convert maps back into arrays.
+            const rollBackState = Object.create(null);
+            for (let i = roomJson.timeline.events.length - 1; i >= 0; i--) {
+                const timelineEvent = roomJson.timeline.events[i];
+                if (timelineEvent.state_key === null || timelineEvent.state_key === undefined) {
+                    continue; // not a state event
+                }
+                // since we're going back in time, we need to use the previous
+                // state value else we'll break causality. We don't have the
+                // complete previous state event, so we need to create one.
+                const prevStateEvent = _utils2.default.deepCopy(timelineEvent);
+                if (prevStateEvent.unsigned) {
+                    if (prevStateEvent.unsigned.prev_content) {
+                        prevStateEvent.content = prevStateEvent.unsigned.prev_content;
+                    }
+                    if (prevStateEvent.unsigned.prev_sender) {
+                        prevStateEvent.sender = prevStateEvent.unsigned.prev_sender;
+                    }
+                }
+                setState(rollBackState, prevStateEvent);
+            }
+            Object.keys(roomData._currentState).forEach(evType => {
+                Object.keys(roomData._currentState[evType]).forEach(stateKey => {
+                    let ev = roomData._currentState[evType][stateKey];
+                    if (rollBackState[evType] && rollBackState[evType][stateKey]) {
+                        // use the reverse clobbered event instead.
+                        ev = rollBackState[evType][stateKey];
+                    }
+                    roomJson.state.events.push(ev);
+                });
+            });
+            data.join[roomId] = roomJson;
+        });
+
+        // Add account data
+        const accData = [];
+        Object.keys(this.accountData).forEach(evType => {
+            accData.push(this.accountData[evType]);
+        });
+
+        return {
+            nextBatch: this.nextBatch,
+            roomsData: data,
+            groupsData: this.groups,
+            accountData: accData
+        };
+    }
+
+    getNextBatchToken() {
+        return this.nextBatch;
+    }
+}
+
+function setState(eventMap, event) {
+    if (event.state_key === null || event.state_key === undefined || !event.type) {
+        return;
+    }
+    if (!eventMap[event.type]) {
+        eventMap[event.type] = Object.create(null);
+    }
+    eventMap[event.type][event.state_key] = event;
+}
+
+module.exports = SyncAccumulator;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/sync.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/sync.js
@@ -1,10 +1,12 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2018 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -18,237 +20,264 @@ limitations under the License.
 /*
  * TODO:
  * This class mainly serves to take all the syncing logic out of client.js and
  * into a separate file. It's all very fluid, and this class gut wrenches a lot
  * of MatrixClient props (e.g. _http). Given we want to support WebSockets as
  * an alternative syncing API, we may want to have a proper syncing interface
  * for HTTP and WS at some point.
  */
-var q = require("q");
-var User = require("./models/user");
-var Room = require("./models/room");
-var utils = require("./utils");
-var Filter = require("./filter");
-var EventTimeline = require("./models/event-timeline");
+
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require("../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+var _errors = require("./errors");
 
-var DEBUG = true;
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const User = require("./models/user");
+const Room = require("./models/room");
+const Group = require('./models/group');
+const utils = require("./utils");
+const Filter = require("./filter");
+const EventTimeline = require("./models/event-timeline");
+const PushProcessor = require("./pushprocessor");
+
+
+const DEBUG = true;
 
 // /sync requests allow you to set a timeout= but the request may continue
 // beyond that and wedge forever, so we need to track how long we are willing
 // to keep open the connection. This constant is *ADDED* to the timeout= value
 // to determine the max time we're willing to wait.
-var BUFFER_PERIOD_MS = 80 * 1000;
+const BUFFER_PERIOD_MS = 80 * 1000;
+
+// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed
+// to RECONNECTING. This is needed to inform the client of server issues when the
+// keepAlive is successful but the server /sync fails.
+const FAILED_SYNC_ERROR_THRESHOLD = 3;
 
 function getFilterName(userId, suffix) {
     // scope this on the user ID because people may login on many accounts
     // and they all need to be stored!
     return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : "");
 }
 
-function debuglog() {
-    if (!DEBUG) { return; }
-    console.log.apply(console, arguments);
+function debuglog(...params) {
+    if (!DEBUG) {
+        return;
+    }
+    _logger2.default.log(...params);
 }
 
-
 /**
  * <b>Internal class - unstable.</b>
  * Construct an entity which is able to sync with a homeserver.
  * @constructor
  * @param {MatrixClient} client The matrix client instance to use.
  * @param {Object} opts Config options
+ * @param {module:crypto=} opts.crypto Crypto manager
+ * @param {Function=} opts.canResetEntireTimeline A function which is called
+ * with a room ID and returns a boolean. It should return 'true' if the SDK can
+ * SAFELY remove events from this room. It may not be safe to remove events if
+ * there are other references to the timelines for this room.
+ * Default: returns false.
+ * @param {Boolean=} opts.disablePresence True to perform syncing without automatically
+ * updating presence.
  */
 function SyncApi(client, opts) {
     this.client = client;
     opts = opts || {};
-    opts.initialSyncLimit = (
-        opts.initialSyncLimit === undefined ? 8 : opts.initialSyncLimit
-    );
+    opts.initialSyncLimit = opts.initialSyncLimit === undefined ? 8 : opts.initialSyncLimit;
     opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false;
-    opts.pollTimeout = opts.pollTimeout || (30 * 1000);
+    opts.pollTimeout = opts.pollTimeout || 30 * 1000;
     opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological";
+    if (!opts.canResetEntireTimeline) {
+        opts.canResetEntireTimeline = function (roomId) {
+            return false;
+        };
+    }
     this.opts = opts;
     this._peekRoomId = null;
     this._currentSyncRequest = null;
     this._syncState = null;
+    this._syncStateData = null; // additional data (eg. error object for failed sync)
+    this._catchingUp = false;
     this._running = false;
     this._keepAliveTimer = null;
     this._connectionReturnedDefer = null;
     this._notifEvents = []; // accumulator of sync events in the current sync response
+    this._failedSyncCount = 0; // Number of consecutive failed /sync requests
+    this._storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
 
     if (client.getNotifTimelineSet()) {
-        reEmit(client, client.getNotifTimelineSet(),
-               ["Room.timeline", "Room.timelineReset"]);
+        client.reEmitter.reEmit(client.getNotifTimelineSet(), ["Room.timeline", "Room.timelineReset"]);
     }
 }
 
 /**
  * @param {string} roomId
  * @return {Room}
  */
-SyncApi.prototype.createRoom = function(roomId) {
-    var client = this.client;
-    var room = new Room(roomId, {
+SyncApi.prototype.createRoom = function (roomId) {
+    const client = this.client;
+    const {
+        timelineSupport,
+        unstableClientRelationAggregation
+    } = client;
+    const room = new Room(roomId, client, client.getUserId(), {
+        lazyLoadMembers: this.opts.lazyLoadMembers,
         pendingEventOrdering: this.opts.pendingEventOrdering,
-        timelineSupport: client.timelineSupport,
+        timelineSupport,
+        unstableClientRelationAggregation
     });
-    reEmit(client, room, ["Room.name", "Room.timeline", "Room.redaction",
-                          "Room.receipt", "Room.tags",
-                          "Room.timelineReset",
-                          "Room.localEchoUpdated",
-                          "Room.accountData",
-                         ]);
+    client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", "Room.redaction", "Room.redactionCancelled", "Room.receipt", "Room.tags", "Room.timelineReset", "Room.localEchoUpdated", "Room.accountData", "Room.myMembership", "Room.replaceEvent"]);
     this._registerStateListeners(room);
     return room;
 };
 
 /**
+ * @param {string} groupId
+ * @return {Group}
+ */
+SyncApi.prototype.createGroup = function (groupId) {
+    const client = this.client;
+    const group = new Group(groupId);
+    client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]);
+    client.store.storeGroup(group);
+    return group;
+};
+
+/**
  * @param {Room} room
  * @private
  */
-SyncApi.prototype._registerStateListeners = function(room) {
-    var client = this.client;
+SyncApi.prototype._registerStateListeners = function (room) {
+    const client = this.client;
     // we need to also re-emit room state and room member events, so hook it up
     // to the client now. We need to add a listener for RoomState.members in
     // order to hook them correctly. (TODO: find a better way?)
-    reEmit(client, room.currentState, [
-        "RoomState.events", "RoomState.members", "RoomState.newMember"
-    ]);
-    room.currentState.on("RoomState.newMember", function(event, state, member) {
+    client.reEmitter.reEmit(room.currentState, ["RoomState.events", "RoomState.members", "RoomState.newMember"]);
+    room.currentState.on("RoomState.newMember", function (event, state, member) {
         member.user = client.getUser(member.userId);
-        reEmit(
-            client, member,
-            [
-                "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel",
-                "RoomMember.membership"
-            ]
-        );
+        client.reEmitter.reEmit(member, ["RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", "RoomMember.membership"]);
     });
 };
 
 /**
  * @param {Room} room
  * @private
  */
-SyncApi.prototype._deregisterStateListeners = function(room) {
+SyncApi.prototype._deregisterStateListeners = function (room) {
     // could do with a better way of achieving this.
     room.currentState.removeAllListeners("RoomState.events");
     room.currentState.removeAllListeners("RoomState.members");
     room.currentState.removeAllListeners("RoomState.newMember");
 };
 
-
 /**
  * Sync rooms the user has left.
  * @return {Promise} Resolved when they've been added to the store.
  */
-SyncApi.prototype.syncLeftRooms = function() {
-    var client = this.client;
-    var self = this;
+SyncApi.prototype.syncLeftRooms = function () {
+    const client = this.client;
+    const self = this;
 
     // grab a filter with limit=1 and include_leave=true
-    var filter = new Filter(this.client.credentials.userId);
+    const filter = new Filter(this.client.credentials.userId);
     filter.setTimelineLimit(1);
     filter.setIncludeLeaveRooms(true);
 
-    var localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS;
-    var qps = {
+    const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS;
+    const qps = {
         timeout: 0 // don't want to block since this is a single isolated req
     };
 
-    return client.getOrCreateFilter(
-        getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter
-    ).then(function(filterId) {
+    return client.getOrCreateFilter(getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter).then(function (filterId) {
         qps.filter = filterId;
-        return client._http.authedRequest(
-            undefined, "GET", "/sync", qps, undefined, localTimeoutMs
-        );
-    }).then(function(data) {
-        var leaveRooms = [];
+        return client._http.authedRequest(undefined, "GET", "/sync", qps, undefined, localTimeoutMs);
+    }).then(function (data) {
+        let leaveRooms = [];
         if (data.rooms && data.rooms.leave) {
             leaveRooms = self._mapSyncResponseToRoomArray(data.rooms.leave);
         }
-        var rooms = [];
-        leaveRooms.forEach(function(leaveObj) {
-            var room = leaveObj.room;
+        const rooms = [];
+        leaveRooms.forEach(function (leaveObj) {
+            const room = leaveObj.room;
             rooms.push(room);
             if (!leaveObj.isBrandNewRoom) {
                 // the intention behind syncLeftRooms is to add in rooms which were
                 // *omitted* from the initial /sync. Rooms the user were joined to
                 // but then left whilst the app is running will appear in this list
                 // and we do not want to bother with them since they will have the
                 // current state already (and may get dupe messages if we add
                 // yet more timeline events!), so skip them.
                 // NB: When we persist rooms to localStorage this will be more
                 //     complicated...
                 return;
             }
             leaveObj.timeline = leaveObj.timeline || {};
-            var timelineEvents =
-                self._mapSyncEventsFormat(leaveObj.timeline, room);
-            var stateEvents = self._mapSyncEventsFormat(leaveObj.state, room);
+            const timelineEvents = self._mapSyncEventsFormat(leaveObj.timeline, room);
+            const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room);
 
             // set the back-pagination token. Do this *before* adding any
             // events so that clients can start back-paginating.
-            room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch,
-                                                      EventTimeline.BACKWARDS);
+            room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS);
 
             self._processRoomEvents(room, stateEvents, timelineEvents);
 
-            room.recalculate(client.credentials.userId);
+            room.recalculate();
             client.store.storeRoom(room);
             client.emit("Room", room);
+
+            self._processEventsForNotifs(room, timelineEvents);
         });
         return rooms;
     });
 };
 
 /**
  * Peek into a room. This will result in the room in question being synced so it
  * is accessible via getRooms(). Live updates for the room will be provided.
  * @param {string} roomId The room ID to peek into.
  * @return {Promise} A promise which resolves once the room has been added to the
  * store.
  */
-SyncApi.prototype.peek = function(roomId) {
-    var self = this;
-    var client = this.client;
+SyncApi.prototype.peek = function (roomId) {
+    const self = this;
+    const client = this.client;
     this._peekRoomId = roomId;
-    return this.client.roomInitialSync(roomId, 20).then(function(response) {
+    return this.client.roomInitialSync(roomId, 20).then(function (response) {
         // make sure things are init'd
         response.messages = response.messages || {};
         response.messages.chunk = response.messages.chunk || [];
         response.state = response.state || [];
 
-        var peekRoom = self.createRoom(roomId);
+        const peekRoom = self.createRoom(roomId);
 
         // FIXME: Mostly duplicated from _processRoomEvents but not entirely
         // because "state" in this API is at the BEGINNING of the chunk
-        var oldStateEvents = utils.map(
-            utils.deepCopy(response.state), client.getEventMapper()
-        );
-        var stateEvents = utils.map(
-            response.state, client.getEventMapper()
-        );
-        var messages = utils.map(
-            response.messages.chunk, client.getEventMapper()
-        );
+        const oldStateEvents = utils.map(utils.deepCopy(response.state), client.getEventMapper());
+        const stateEvents = utils.map(response.state, client.getEventMapper());
+        const messages = utils.map(response.messages.chunk, client.getEventMapper());
 
         // XXX: copypasted from /sync until we kill off this
         // minging v1 API stuff)
         // handle presence events (User objects)
         if (response.presence && utils.isArray(response.presence)) {
-            response.presence.map(client.getEventMapper()).forEach(
-            function(presenceEvent) {
-                var user = client.store.getUser(presenceEvent.getContent().user_id);
+            response.presence.map(client.getEventMapper()).forEach(function (presenceEvent) {
+                let user = client.store.getUser(presenceEvent.getContent().user_id);
                 if (user) {
                     user.setPresenceEvent(presenceEvent);
-                }
-                else {
+                } else {
                     user = createNewUser(client, presenceEvent.getContent().user_id);
                     user.setPresenceEvent(presenceEvent);
                     client.store.storeUser(user);
                 }
                 client.emit("event", presenceEvent);
             });
         }
 
@@ -259,239 +288,545 @@ SyncApi.prototype.peek = function(roomId
             peekRoom.oldState.paginationToken = response.messages.start;
         }
 
         // set the state of the room to as it was after the timeline executes
         peekRoom.oldState.setStateEvents(oldStateEvents);
         peekRoom.currentState.setStateEvents(stateEvents);
 
         self._resolveInvites(peekRoom);
-        peekRoom.recalculate(self.client.credentials.userId);
+        peekRoom.recalculate();
 
         // roll backwards to diverge old state. addEventsToTimeline
         // will overwrite the pagination token, so make sure it overwrites
         // it with the right thing.
-        peekRoom.addEventsToTimeline(messages.reverse(), true,
-                                     peekRoom.getLiveTimeline(),
-                                     response.messages.start);
+        peekRoom.addEventsToTimeline(messages.reverse(), true, peekRoom.getLiveTimeline(), response.messages.start);
 
         client.store.storeRoom(peekRoom);
         client.emit("Room", peekRoom);
 
-        self._peekPoll(roomId);
+        self._peekPoll(peekRoom);
         return peekRoom;
     });
 };
 
 /**
  * Stop polling for updates in the peeked room. NOPs if there is no room being
  * peeked.
  */
-SyncApi.prototype.stopPeeking = function() {
+SyncApi.prototype.stopPeeking = function () {
     this._peekRoomId = null;
 };
 
 /**
  * Do a peek room poll.
- * @param {string} roomId
+ * @param {Room} peekRoom
  * @param {string} token from= token
  */
-SyncApi.prototype._peekPoll = function(roomId, token) {
-    if (this._peekRoomId !== roomId) {
-        debuglog("Stopped peeking in room %s", roomId);
+SyncApi.prototype._peekPoll = function (peekRoom, token) {
+    if (this._peekRoomId !== peekRoom.roomId) {
+        debuglog("Stopped peeking in room %s", peekRoom.roomId);
         return;
     }
 
-    var self = this;
+    const self = this;
     // FIXME: gut wrenching; hard-coded timeout values
     this.client._http.authedRequest(undefined, "GET", "/events", {
-        room_id: roomId,
+        room_id: peekRoom.roomId,
         timeout: 30 * 1000,
         from: token
-    }, undefined, 50 * 1000).done(function(res) {
-
+    }, undefined, 50 * 1000).done(function (res) {
+        if (self._peekRoomId !== peekRoom.roomId) {
+            debuglog("Stopped peeking in room %s", peekRoom.roomId);
+            return;
+        }
         // We have a problem that we get presence both from /events and /sync
         // however, /sync only returns presence for users in rooms
         // you're actually joined to.
         // in order to be sure to get presence for all of the users in the
         // peeked room, we handle presence explicitly here. This may result
         // in duplicate presence events firing for some users, which is a
         // performance drain, but such is life.
         // XXX: copypasted from /sync until we can kill this minging v1 stuff.
 
-        res.chunk.filter(function(e) {
+        res.chunk.filter(function (e) {
             return e.type === "m.presence";
-        }).map(self.client.getEventMapper()).forEach(function(presenceEvent) {
-            var user = self.client.store.getUser(presenceEvent.getContent().user_id);
+        }).map(self.client.getEventMapper()).forEach(function (presenceEvent) {
+            let user = self.client.store.getUser(presenceEvent.getContent().user_id);
             if (user) {
                 user.setPresenceEvent(presenceEvent);
-            }
-            else {
+            } else {
                 user = createNewUser(self.client, presenceEvent.getContent().user_id);
                 user.setPresenceEvent(presenceEvent);
                 self.client.store.storeUser(user);
             }
             self.client.emit("event", presenceEvent);
         });
 
         // strip out events which aren't for the given room_id (e.g presence)
-        var events = res.chunk.filter(function(e) {
-            return e.room_id === roomId;
+        const events = res.chunk.filter(function (e) {
+            return e.room_id === peekRoom.roomId;
         }).map(self.client.getEventMapper());
-        var room = self.client.getRoom(roomId);
-        room.addLiveEvents(events);
-        self._peekPoll(roomId, res.end);
-    }, function(err) {
-        console.error("[%s] Peek poll failed: %s", roomId, err);
-        setTimeout(function() {
-            self._peekPoll(roomId, token);
+
+        peekRoom.addLiveEvents(events);
+        self._peekPoll(peekRoom, res.end);
+    }, function (err) {
+        _logger2.default.error("[%s] Peek poll failed: %s", peekRoom.roomId, err);
+        setTimeout(function () {
+            self._peekPoll(peekRoom, token);
         }, 30 * 1000);
     });
 };
 
 /**
  * Returns the current state of this sync object
  * @see module:client~MatrixClient#event:"sync"
  * @return {?String}
  */
-SyncApi.prototype.getSyncState = function() {
+SyncApi.prototype.getSyncState = function () {
     return this._syncState;
 };
 
 /**
+ * Returns the additional data object associated with
+ * the current sync state, or null if there is no
+ * such data.
+ * Sync errors, if available, are put in the 'error' key of
+ * this object.
+ * @return {?Object}
+ */
+SyncApi.prototype.getSyncStateData = function () {
+    return this._syncStateData;
+};
+
+SyncApi.prototype.recoverFromSyncStartupError = async function (savedSyncPromise, err) {
+    // Wait for the saved sync to complete - we send the pushrules and filter requests
+    // before the saved sync has finished so they can run in parallel, but only process
+    // the results after the saved sync is done. Equivalently, we wait for it to finish
+    // before reporting failures from these functions.
+    await savedSyncPromise;
+    const keepaliveProm = this._startKeepAlives();
+    this._updateSyncState("ERROR", { error: err });
+    await keepaliveProm;
+};
+
+/**
+ * Is the lazy loading option different than in previous session?
+ * @param {bool} lazyLoadMembers current options for lazy loading
+ * @return {bool} whether or not the option has changed compared to the previous session */
+SyncApi.prototype._wasLazyLoadingToggled = async function (lazyLoadMembers) {
+    lazyLoadMembers = !!lazyLoadMembers;
+    // assume it was turned off before
+    // if we don't know any better
+    let lazyLoadMembersBefore = false;
+    const isStoreNewlyCreated = await this.client.store.isNewlyCreated();
+    if (!isStoreNewlyCreated) {
+        const prevClientOptions = await this.client.store.getClientOptions();
+        if (prevClientOptions) {
+            lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers;
+        }
+        return lazyLoadMembersBefore !== lazyLoadMembers;
+    }
+    return false;
+};
+
+SyncApi.prototype._shouldAbortSync = function (error) {
+    if (error.errcode === "M_UNKNOWN_TOKEN") {
+        // The logout already happened, we just need to stop.
+        _logger2.default.warn("Token no longer valid - assuming logout");
+        this.stop();
+        return true;
+    }
+    return false;
+};
+
+/**
  * Main entry point
  */
-SyncApi.prototype.sync = function() {
-    debuglog("SyncApi.sync: starting with sync token " +
-             this.client.store.getSyncToken());
-
-    var client = this.client;
-    var self = this;
+SyncApi.prototype.sync = function () {
+    const client = this.client;
+    const self = this;
 
     this._running = true;
 
     if (global.document) {
         this._onOnlineBound = this._onOnline.bind(this);
-        global.document.addEventListener("online", this._onOnlineBound);
+        global.document.addEventListener("online", this._onOnlineBound, false);
     }
 
+    let savedSyncPromise = _bluebird2.default.resolve();
+    let savedSyncToken = null;
+
     // We need to do one-off checks before we can begin the /sync loop.
     // These are:
     //   1) We need to get push rules so we can check if events should bing as we get
     //      them from /sync.
     //   2) We need to get/create a filter which we can use for /sync.
+    //   3) We need to check the lazy loading option matches what was used in the
+    //       stored sync. If it doesn't, we can't use the stored sync.
 
-    function getPushRules() {
-        client.getPushRules().done(function(result) {
+    async function getPushRules() {
+        try {
+            debuglog("Getting push rules...");
+            const result = await client.getPushRules();
             debuglog("Got push rules");
+
             client.pushRules = result;
-            getFilter(); // Now get the filter
-        }, function(err) {
-            self._startKeepAlives().done(function() {
-                getPushRules();
-            });
-            self._updateSyncState("ERROR", { error: err });
-        });
+        } catch (err) {
+            _logger2.default.error("Getting push rules failed", err);
+            if (self._shouldAbortSync(err)) return;
+            // wait for saved sync to complete before doing anything else,
+            // otherwise the sync state will end up being incorrect
+            debuglog("Waiting for saved sync before retrying push rules...");
+            await self.recoverFromSyncStartupError(savedSyncPromise, err);
+            getPushRules();
+            return;
+        }
+        checkLazyLoadStatus(); // advance to the next stage
     }
 
-    function getFilter() {
-        var filter;
+    const checkLazyLoadStatus = async () => {
+        debuglog("Checking lazy load status...");
+        if (this.opts.lazyLoadMembers && client.isGuest()) {
+            this.opts.lazyLoadMembers = false;
+        }
+        if (this.opts.lazyLoadMembers) {
+            debuglog("Checking server lazy load support...");
+            const supported = await client.doesServerSupportLazyLoading();
+            if (supported) {
+                try {
+                    debuglog("Creating and storing lazy load sync filter...");
+                    this.opts.filter = await client.createFilter(Filter.LAZY_LOADING_SYNC_FILTER);
+                    debuglog("Created and stored lazy load sync filter");
+                } catch (err) {
+                    _logger2.default.error("Creating and storing lazy load sync filter failed", err);
+                    throw err;
+                }
+            } else {
+                debuglog("LL: lazy loading requested but not supported " + "by server, so disabling");
+                this.opts.lazyLoadMembers = false;
+            }
+        }
+        // need to vape the store when enabling LL and wasn't enabled before
+        debuglog("Checking whether lazy loading has changed in store...");
+        const shouldClear = await this._wasLazyLoadingToggled(this.opts.lazyLoadMembers);
+        if (shouldClear) {
+            this._storeIsInvalid = true;
+            const reason = _errors.InvalidStoreError.TOGGLED_LAZY_LOADING;
+            const error = new _errors.InvalidStoreError(reason, !!this.opts.lazyLoadMembers);
+            this._updateSyncState("ERROR", { error });
+            // bail out of the sync loop now: the app needs to respond to this error.
+            // we leave the state as 'ERROR' which isn't great since this normally means
+            // we're retrying. The client must be stopped before clearing the stores anyway
+            // so the app should stop the client, clear the store and start it again.
+            _logger2.default.warn("InvalidStoreError: store is not usable: stopping sync.");
+            return;
+        }
+        if (this.opts.lazyLoadMembers && this.opts.crypto) {
+            this.opts.crypto.enableLazyLoading();
+        }
+        try {
+            debuglog("Storing client options...");
+            await this.client._storeClientOptions();
+            debuglog("Stored client options");
+        } catch (err) {
+            _logger2.default.error("Storing client options failed", err);
+            throw err;
+        }
+
+        getFilter(); // Now get the filter and start syncing
+    };
+
+    async function getFilter() {
+        debuglog("Getting filter...");
+        let filter;
         if (self.opts.filter) {
             filter = self.opts.filter;
         } else {
             filter = new Filter(client.credentials.userId);
             filter.setTimelineLimit(self.opts.initialSyncLimit);
         }
 
-        client.getOrCreateFilter(
-            getFilterName(client.credentials.userId), filter
-        ).done(function(filterId) {
-            // reset the notifications timeline to prepare it to paginate from
-            // the current point in time.
-            // The right solution would be to tie /sync pagination tokens into
-            // /notifications API somehow.
-            client.resetNotifTimelineSet();
+        let filterId;
+        try {
+            filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId), filter);
+        } catch (err) {
+            _logger2.default.error("Getting filter failed", err);
+            if (self._shouldAbortSync(err)) return;
+            // wait for saved sync to complete before doing anything else,
+            // otherwise the sync state will end up being incorrect
+            debuglog("Waiting for saved sync before retrying filter...");
+            await self.recoverFromSyncStartupError(savedSyncPromise, err);
+            getFilter();
+            return;
+        }
+        // reset the notifications timeline to prepare it to paginate from
+        // the current point in time.
+        // The right solution would be to tie /sync pagination tokens into
+        // /notifications API somehow.
+        client.resetNotifTimelineSet();
 
-            self._sync({ filterId: filterId });
-        }, function(err) {
-            self._startKeepAlives().done(function() {
-                getFilter();
-            });
-            self._updateSyncState("ERROR", { error: err });
-        });
+        if (self._currentSyncRequest === null) {
+            // Send this first sync request here so we can then wait for the saved
+            // sync data to finish processing before we process the results of this one.
+            debuglog("Sending first sync request...");
+            self._currentSyncRequest = self._doSyncRequest({ filterId }, savedSyncToken);
+        }
+
+        // Now wait for the saved sync to finish...
+        debuglog("Waiting for saved sync before starting sync processing...");
+        await savedSyncPromise;
+        self._sync({ filterId });
     }
 
     if (client.isGuest()) {
         // no push rules for guests, no access to POST filter for guests.
         self._sync({});
-    }
-    else {
+    } else {
+        // Pull the saved sync token out first, before the worker starts sending
+        // all the sync data which could take a while. This will let us send our
+        // first incremental sync request before we've processed our saved data.
+        debuglog("Getting saved sync token...");
+        savedSyncPromise = client.store.getSavedSyncToken().then(tok => {
+            debuglog("Got saved sync token");
+            savedSyncToken = tok;
+            debuglog("Getting saved sync...");
+            return client.store.getSavedSync();
+        }).then(savedSync => {
+            debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
+            if (savedSync) {
+                return self._syncFromCache(savedSync);
+            }
+        }).catch(err => {
+            _logger2.default.error("Getting saved sync failed", err);
+        });
+        // Now start the first incremental sync request: this can also
+        // take a while so if we set it going now, we can wait for it
+        // to finish while we process our saved sync data.
         getPushRules();
     }
 };
 
 /**
  * Stops the sync object from syncing.
  */
-SyncApi.prototype.stop = function() {
+SyncApi.prototype.stop = function () {
     debuglog("SyncApi.stop");
     if (global.document) {
-        global.document.removeEventListener("online", this._onOnlineBound);
+        global.document.removeEventListener("online", this._onOnlineBound, false);
         this._onOnlineBound = undefined;
     }
     this._running = false;
-    if (this._currentSyncRequest) { this._currentSyncRequest.abort(); }
+    if (this._currentSyncRequest) {
+        this._currentSyncRequest.abort();
+    }
     if (this._keepAliveTimer) {
         clearTimeout(this._keepAliveTimer);
         this._keepAliveTimer = null;
     }
 };
 
 /**
  * Retry a backed off syncing request immediately. This should only be used when
  * the user <b>explicitly</b> attempts to retry their lost connection.
  * @return {boolean} True if this resulted in a request being retried.
  */
-SyncApi.prototype.retryImmediately = function() {
-    if (!this._connectionReturnedDefer) { return false; }
+SyncApi.prototype.retryImmediately = function () {
+    if (!this._connectionReturnedDefer) {
+        return false;
+    }
     this._startKeepAlives(0);
     return true;
 };
+/**
+ * Process a single set of cached sync data.
+ * @param {Object} savedSync a saved sync that was persisted by a store. This
+ * should have been acquired via client.store.getSavedSync().
+ */
+SyncApi.prototype._syncFromCache = async function (savedSync) {
+    debuglog("sync(): not doing HTTP hit, instead returning stored /sync data");
+
+    const nextSyncToken = savedSync.nextBatch;
+
+    // Set sync token for future incremental syncing
+    this.client.store.setSyncToken(nextSyncToken);
+
+    // No previous sync, set old token to null
+    const syncEventData = {
+        oldSyncToken: null,
+        nextSyncToken,
+        catchingUp: false
+    };
+
+    const data = {
+        next_batch: nextSyncToken,
+        rooms: savedSync.roomsData,
+        groups: savedSync.groupsData,
+        account_data: {
+            events: savedSync.accountData
+        }
+    };
+
+    try {
+        await this._processSyncResponse(syncEventData, data);
+    } catch (e) {
+        _logger2.default.error("Error processing cached sync", e.stack || e);
+    }
+
+    // Don't emit a prepared if we've bailed because the store is invalid:
+    // in this case the client will not be usable until stopped & restarted
+    // so this would be useless and misleading.
+    if (!this._storeIsInvalid) {
+        this._updateSyncState("PREPARED", syncEventData);
+    }
+};
 
 /**
  * Invoke me to do /sync calls
  * @param {Object} syncOptions
  * @param {string} syncOptions.filterId
  * @param {boolean} syncOptions.hasSyncedBefore
  */
-SyncApi.prototype._sync = function(syncOptions) {
-    var client = this.client;
-    var self = this;
+SyncApi.prototype._sync = async function (syncOptions) {
+    const client = this.client;
 
     if (!this._running) {
         debuglog("Sync no longer running: exiting.");
-        if (self._connectionReturnedDefer) {
-            self._connectionReturnedDefer.reject();
-            self._connectionReturnedDefer = null;
+        if (this._connectionReturnedDefer) {
+            this._connectionReturnedDefer.reject();
+            this._connectionReturnedDefer = null;
         }
         this._updateSyncState("STOPPED");
         return;
     }
 
-    var filterId = syncOptions.filterId;
-    if (client.isGuest() && !filterId) {
+    const syncToken = client.store.getSyncToken();
+
+    let data;
+    try {
+        //debuglog('Starting sync since=' + syncToken);
+        if (this._currentSyncRequest === null) {
+            this._currentSyncRequest = this._doSyncRequest(syncOptions, syncToken);
+        }
+        data = await this._currentSyncRequest;
+    } catch (e) {
+        this._onSyncError(e, syncOptions);
+        return;
+    } finally {
+        this._currentSyncRequest = null;
+    }
+
+    //debuglog('Completed sync, next_batch=' + data.next_batch);
+
+    // set the sync token NOW *before* processing the events. We do this so
+    // if something barfs on an event we can skip it rather than constantly
+    // polling with the same token.
+    client.store.setSyncToken(data.next_batch);
+
+    // Reset after a successful sync
+    this._failedSyncCount = 0;
+
+    await client.store.setSyncData(data);
+
+    const syncEventData = {
+        oldSyncToken: syncToken,
+        nextSyncToken: data.next_batch,
+        catchingUp: this._catchingUp
+    };
+
+    if (this.opts.crypto) {
+        // tell the crypto module we're about to process a sync
+        // response
+        await this.opts.crypto.onSyncWillProcess(syncEventData);
+    }
+
+    try {
+        await this._processSyncResponse(syncEventData, data);
+    } catch (e) {
+        // log the exception with stack if we have it, else fall back
+        // to the plain description
+        _logger2.default.error("Caught /sync error", e.stack || e);
+
+        // Emit the exception for client handling
+        this.client.emit("sync.unexpectedError", e);
+    }
+
+    // update this as it may have changed
+    syncEventData.catchingUp = this._catchingUp;
+
+    // emit synced events
+    if (!syncOptions.hasSyncedBefore) {
+        this._updateSyncState("PREPARED", syncEventData);
+        syncOptions.hasSyncedBefore = true;
+    }
+
+    // tell the crypto module to do its processing. It may block (to do a
+    // /keys/changes request).
+    if (this.opts.crypto) {
+        await this.opts.crypto.onSyncCompleted(syncEventData);
+    }
+
+    // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
+    this._updateSyncState("SYNCING", syncEventData);
+
+    if (client.store.wantsSave()) {
+        // We always save the device list (if it's dirty) before saving the sync data:
+        // this means we know the saved device list data is at least as fresh as the
+        // stored sync data which means we don't have to worry that we may have missed
+        // device changes. We can also skip the delay since we're not calling this very
+        // frequently (and we don't really want to delay the sync for it).
+        if (this.opts.crypto) {
+            await this.opts.crypto.saveDeviceList(0);
+        }
+
+        // tell databases that everything is now in a consistent state and can be saved.
+        client.store.save();
+    }
+
+    // Begin next sync
+    this._sync(syncOptions);
+};
+
+SyncApi.prototype._doSyncRequest = function (syncOptions, syncToken) {
+    const qps = this._getSyncParams(syncOptions, syncToken);
+    return this.client._http.authedRequest(undefined, "GET", "/sync", qps, undefined, qps.timeout + BUFFER_PERIOD_MS);
+};
+
+SyncApi.prototype._getSyncParams = function (syncOptions, syncToken) {
+    let pollTimeout = this.opts.pollTimeout;
+
+    if (this.getSyncState() !== 'SYNCING' || this._catchingUp) {
+        // unless we are happily syncing already, we want the server to return
+        // as quickly as possible, even if there are no events queued. This
+        // serves two purposes:
+        //
+        // * When the connection dies, we want to know asap when it comes back,
+        //   so that we can hide the error from the user. (We don't want to
+        //   have to wait for an event or a timeout).
+        //
+        // * We want to know if the server has any to_device messages queued up
+        //   for us. We do that by calling it with a zero timeout until it
+        //   doesn't give us any more to_device messages.
+        this._catchingUp = true;
+        pollTimeout = 0;
+    }
+
+    let filterId = syncOptions.filterId;
+    if (this.client.isGuest() && !filterId) {
         filterId = this._getGuestFilter();
     }
 
-    var syncToken = client.store.getSyncToken();
+    const qps = {
+        filter: filterId,
+        timeout: pollTimeout
+    };
 
-    var qps = {
-        filter: filterId,
-        timeout: this.opts.pollTimeout,
-    };
+    if (this.opts.disablePresence) {
+        qps.set_presence = "offline";
+    }
 
     if (syncToken) {
         qps.since = syncToken;
     } else {
         // use a cachebuster for initialsyncs, to make sure that
         // we don't get a stale sync
         // (https://github.com/vector-im/vector-web/issues/1354)
         qps._cacheBuster = Date.now();
@@ -500,262 +835,311 @@ SyncApi.prototype._sync = function(syncO
     if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') {
         // we think the connection is dead. If it comes back up, we won't know
         // about it till /sync returns. If the timeout= is high, this could
         // be a long time. Set it to 0 when doing retries so we don't have to wait
         // for an event or a timeout before emiting the SYNCING event.
         qps.timeout = 0;
     }
 
-    // normal timeout= plus buffer time
-    var clientSideTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS;
-
-    this._currentSyncRequest = client._http.authedRequest(
-        undefined, "GET", "/sync", qps, undefined, clientSideTimeoutMs
-    );
-
-    this._currentSyncRequest.done(function(data) {
-        // set the sync token NOW *before* processing the events. We do this so
-        // if something barfs on an event we can skip it rather than constantly
-        // polling with the same token.
-        client.store.setSyncToken(data.next_batch);
+    return qps;
+};
 
-        try {
-            self._processSyncResponse(syncToken, data);
+SyncApi.prototype._onSyncError = function (err, syncOptions) {
+    if (!this._running) {
+        debuglog("Sync no longer running: exiting");
+        if (this._connectionReturnedDefer) {
+            this._connectionReturnedDefer.reject();
+            this._connectionReturnedDefer = null;
         }
-        catch (e) {
-            // log the exception with stack if we have it, else fall back
-            // to the plain description
-            console.error("Caught /sync error", e.stack || e);
-        }
+        this._updateSyncState("STOPPED");
+        return;
+    }
+
+    _logger2.default.error("/sync error %s", err);
+    _logger2.default.error(err);
 
-        // emit synced events
-        if (!syncOptions.hasSyncedBefore) {
-            self._updateSyncState("PREPARED");
-            syncOptions.hasSyncedBefore = true;
-        }
+    if (this._shouldAbortSync(err)) {
+        return;
+    }
+
+    this._failedSyncCount++;
+    _logger2.default.log('Number of consecutive failed sync requests:', this._failedSyncCount);
 
-        // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
-        self._updateSyncState("SYNCING");
-
-        self._sync(syncOptions);
-    }, function(err) {
-        if (!self._running) {
-            debuglog("Sync no longer running: exiting");
-            if (self._connectionReturnedDefer) {
-                self._connectionReturnedDefer.reject();
-                self._connectionReturnedDefer = null;
-            }
-            self._updateSyncState("STOPPED");
-            return;
+    debuglog("Starting keep-alive");
+    // Note that we do *not* mark the sync connection as
+    // lost yet: we only do this if a keepalive poke
+    // fails, since long lived HTTP connections will
+    // go away sometimes and we shouldn't treat this as
+    // erroneous. We set the state to 'reconnecting'
+    // instead, so that clients can observe this state
+    // if they wish.
+    this._startKeepAlives().then(connDidFail => {
+        // Only emit CATCHUP if we detected a connectivity error: if we didn't,
+        // it's quite likely the sync will fail again for the same reason and we
+        // want to stay in ERROR rather than keep flip-flopping between ERROR
+        // and CATCHUP.
+        if (connDidFail && this.getSyncState() === 'ERROR') {
+            this._updateSyncState("CATCHUP", {
+                oldSyncToken: null,
+                nextSyncToken: null,
+                catchingUp: true
+            });
         }
-        console.error("/sync error %s", err);
-        console.error(err);
+        this._sync(syncOptions);
+    });
 
-        debuglog("Starting keep-alive");
-        // Note that we do *not* mark the sync connection as
-        // lost yet: we only do this if a keepalive poke
-        // fails, since long lived HTTP connections will
-        // go away sometimes and we shouldn't treat this as
-        // erroneous. We set the state to 'reconnecting'
-        // instead, so that clients can onserve this state
-        // if they wish.
-        self._startKeepAlives().done(function() {
-            self._sync(syncOptions);
-        });
-        self._currentSyncRequest = null;
-        self._updateSyncState("RECONNECTING");
-    });
+    this._currentSyncRequest = null;
+    // Transition from RECONNECTING to ERROR after a given number of failed syncs
+    this._updateSyncState(this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? "ERROR" : "RECONNECTING", { error: err });
 };
 
 /**
  * Process data returned from a sync response and propagate it
  * into the model objects
  *
- * @param {string} syncToken the old next_batch token sent to this
- *    sync request.
+ * @param {Object} syncEventData Object containing sync tokens associated with this sync
  * @param {Object} data The response from /sync
  */
-SyncApi.prototype._processSyncResponse = function(syncToken, data) {
-    var client = this.client;
-    var self = this;
+SyncApi.prototype._processSyncResponse = async function (syncEventData, data) {
+    const client = this.client;
+    const self = this;
 
     // data looks like:
     // {
     //    next_batch: $token,
     //    presence: { events: [] },
     //    account_data: { events: [] },
+    //    device_lists: { changed: ["@user:server", ... ]},
     //    to_device: { events: [] },
+    //    device_one_time_keys_count: { signed_curve25519: 42 },
     //    rooms: {
     //      invite: {
     //        $roomid: {
     //          invite_state: { events: [] }
     //        }
     //      },
     //      join: {
     //        $roomid: {
     //          state: { events: [] },
     //          timeline: { events: [], prev_batch: $token, limited: true },
     //          ephemeral: { events: [] },
+    //          summary: {
+    //             m.heroes: [ $user_id ],
+    //             m.joined_member_count: $count,
+    //             m.invited_member_count: $count
+    //          },
     //          account_data: { events: [] },
     //          unread_notifications: {
     //              highlight_count: 0,
     //              notification_count: 0,
     //          }
     //        }
     //      },
     //      leave: {
     //        $roomid: {
     //          state: { events: [] },
     //          timeline: { events: [], prev_batch: $token }
     //        }
     //      }
     //    },
+    //    groups: {
+    //        invite: {
+    //            $groupId: {
+    //                inviter: $inviter,
+    //                profile: {
+    //                    avatar_url: $avatarUrl,
+    //                    name: $groupName,
+    //                },
+    //            },
+    //        },
+    //        join: {},
+    //        leave: {},
+    //    },
     // }
 
     // TODO-arch:
     // - Each event we pass through needs to be emitted via 'event', can we
     //   do this in one place?
     // - The isBrandNewRoom boilerplate is boilerplatey.
 
     // handle presence events (User objects)
     if (data.presence && utils.isArray(data.presence.events)) {
-        data.presence.events.map(client.getEventMapper()).forEach(
-        function(presenceEvent) {
-            var user = client.store.getUser(presenceEvent.getSender());
+        data.presence.events.map(client.getEventMapper()).forEach(function (presenceEvent) {
+            let user = client.store.getUser(presenceEvent.getSender());
             if (user) {
                 user.setPresenceEvent(presenceEvent);
-            }
-            else {
+            } else {
                 user = createNewUser(client, presenceEvent.getSender());
                 user.setPresenceEvent(presenceEvent);
                 client.store.storeUser(user);
             }
             client.emit("event", presenceEvent);
         });
     }
 
     // handle non-room account_data
     if (data.account_data && utils.isArray(data.account_data.events)) {
-        var events = data.account_data.events.map(client.getEventMapper());
+        const events = data.account_data.events.map(client.getEventMapper());
         client.store.storeAccountDataEvents(events);
-        events.forEach(
-            function(accountDataEvent) {
-                if (accountDataEvent.getType() == 'm.push_rules') {
-                    client.pushRules = accountDataEvent.getContent();
-                }
-                client.emit("accountData", accountDataEvent);
-                return accountDataEvent;
+        events.forEach(function (accountDataEvent) {
+            // Honour push rules that come down the sync stream but also
+            // honour push rules that were previously cached. Base rules
+            // will be updated when we recieve push rules via getPushRules
+            // (see SyncApi.prototype.sync) before syncing over the network.
+            if (accountDataEvent.getType() === 'm.push_rules') {
+                const rules = accountDataEvent.getContent();
+                client.pushRules = PushProcessor.rewriteDefaultRules(rules);
             }
-        );
+            client.emit("accountData", accountDataEvent);
+            return accountDataEvent;
+        });
     }
 
     // handle to-device events
-    if (data.to_device && utils.isArray(data.to_device.events)) {
-        data.to_device.events
-            .map(client.getEventMapper())
-            .forEach(
-                function(toDeviceEvent) {
-                    var content = toDeviceEvent.getContent();
-                    if (
-                        toDeviceEvent.getType() == "m.room.message" &&
-                            content.msgtype == "m.bad.encrypted"
-                    ) {
-                        console.warn(
-                            "Unable to decrypt to-device event: " + content.body
-                        );
-                        return;
-                    }
+    if (data.to_device && utils.isArray(data.to_device.events) && data.to_device.events.length > 0) {
+        const cancelledKeyVerificationTxns = [];
+        data.to_device.events.map(client.getEventMapper()).map(toDeviceEvent => {
+            // map is a cheap inline forEach
+            // We want to flag m.key.verification.start events as cancelled
+            // if there's an accompanying m.key.verification.cancel event, so
+            // we pull out the transaction IDs from the cancellation events
+            // so we can flag the verification events as cancelled in the loop
+            // below.
+            if (toDeviceEvent.getType() === "m.key.verification.cancel") {
+                const txnId = toDeviceEvent.getContent()['transaction_id'];
+                if (txnId) {
+                    cancelledKeyVerificationTxns.push(txnId);
+                }
+            }
+
+            // as mentioned above, .map is a cheap inline forEach, so return
+            // the unmodified event.
+            return toDeviceEvent;
+        }).forEach(function (toDeviceEvent) {
+            const content = toDeviceEvent.getContent();
+            if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
+                // the mapper already logged a warning.
+                _logger2.default.log('Ignoring undecryptable to-device event from ' + toDeviceEvent.getSender());
+                return;
+            }
 
-                    client.emit("toDeviceEvent", toDeviceEvent);
+            if (toDeviceEvent.getType() === "m.key.verification.start" || toDeviceEvent.getType() === "m.key.verification.request") {
+                const txnId = content['transaction_id'];
+                if (cancelledKeyVerificationTxns.includes(txnId)) {
+                    toDeviceEvent.flagCancelled();
                 }
-            );
+            }
+
+            client.emit("toDeviceEvent", toDeviceEvent);
+        });
+    } else {
+        // no more to-device events: we can stop polling with a short timeout.
+        this._catchingUp = false;
+    }
+
+    if (data.groups) {
+        if (data.groups.invite) {
+            this._processGroupSyncEntry(data.groups.invite, 'invite');
+        }
+
+        if (data.groups.join) {
+            this._processGroupSyncEntry(data.groups.join, 'join');
+        }
+
+        if (data.groups.leave) {
+            this._processGroupSyncEntry(data.groups.leave, 'leave');
+        }
     }
 
     // the returned json structure is a bit crap, so make it into a
     // nicer form (array) after applying sanity to make sure we don't fail
     // on missing keys (on the off chance)
-    var inviteRooms = [];
-    var joinRooms = [];
-    var leaveRooms = [];
+    let inviteRooms = [];
+    let joinRooms = [];
+    let leaveRooms = [];
 
     if (data.rooms) {
         if (data.rooms.invite) {
             inviteRooms = this._mapSyncResponseToRoomArray(data.rooms.invite);
         }
         if (data.rooms.join) {
             joinRooms = this._mapSyncResponseToRoomArray(data.rooms.join);
         }
         if (data.rooms.leave) {
             leaveRooms = this._mapSyncResponseToRoomArray(data.rooms.leave);
         }
     }
 
     this._notifEvents = [];
 
     // Handle invites
-    inviteRooms.forEach(function(inviteObj) {
-        var room = inviteObj.room;
-        var stateEvents =
-            self._mapSyncEventsFormat(inviteObj.invite_state, room);
+    inviteRooms.forEach(function (inviteObj) {
+        const room = inviteObj.room;
+        const stateEvents = self._mapSyncEventsFormat(inviteObj.invite_state, room);
+
         self._processRoomEvents(room, stateEvents);
         if (inviteObj.isBrandNewRoom) {
-            room.recalculate(client.credentials.userId);
+            room.recalculate();
             client.store.storeRoom(room);
             client.emit("Room", room);
         }
-        stateEvents.forEach(function(e) { client.emit("event", e); });
+        stateEvents.forEach(function (e) {
+            client.emit("event", e);
+        });
+        room.updateMyMembership("invite");
     });
 
     // Handle joins
-    joinRooms.forEach(function(joinObj) {
-        var room = joinObj.room;
-        var stateEvents = self._mapSyncEventsFormat(joinObj.state, room);
-        var timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room);
-        var ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral);
-        var accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data);
+    await _bluebird2.default.mapSeries(joinRooms, async function (joinObj) {
+        const room = joinObj.room;
+        const stateEvents = self._mapSyncEventsFormat(joinObj.state, room);
+        const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room);
+        const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral);
+        const accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data);
 
         // we do this first so it's correct when any of the events fire
         if (joinObj.unread_notifications) {
-            room.setUnreadNotificationCount(
-                'total', joinObj.unread_notifications.notification_count
-            );
-            room.setUnreadNotificationCount(
-                'highlight', joinObj.unread_notifications.highlight_count
-            );
+            room.setUnreadNotificationCount('total', joinObj.unread_notifications.notification_count);
+
+            // We track unread notifications ourselves in encrypted rooms, so don't
+            // bother setting it here. We trust our calculations better than the
+            // server's for this case, and therefore will assume that our non-zero
+            // count is accurate.
+            const encrypted = client.isRoomEncrypted(room.roomId);
+            if (!encrypted || encrypted && room.getUnreadNotificationCount('highlight') <= 0) {
+                room.setUnreadNotificationCount('highlight', joinObj.unread_notifications.highlight_count);
+            }
         }
 
         joinObj.timeline = joinObj.timeline || {};
 
         if (joinObj.isBrandNewRoom) {
             // set the back-pagination token. Do this *before* adding any
             // events so that clients can start back-paginating.
-            room.getLiveTimeline().setPaginationToken(
-                joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
-        }
-        else if (joinObj.timeline.limited) {
-            var limited = true;
+            room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
+        } else if (joinObj.timeline.limited) {
+            let limited = true;
 
             // we've got a limited sync, so we *probably* have a gap in the
             // timeline, so should reset. But we might have been peeking or
             // paginating and already have some of the events, in which
             // case we just want to append any subsequent events to the end
             // of the existing timeline.
             //
             // This is particularly important in the case that we already have
             // *all* of the events in the timeline - in that case, if we reset
             // the timeline, we'll end up with an entirely empty timeline,
             // which we'll try to paginate but not get any new events (which
             // will stop us linking the empty timeline into the chain).
             //
-            for (var i = timelineEvents.length - 1; i >= 0; i--) {
-                var eventId = timelineEvents[i].getId();
+            for (let i = timelineEvents.length - 1; i >= 0; i--) {
+                const eventId = timelineEvents[i].getId();
                 if (room.getTimelineForEvent(eventId)) {
-                    debuglog("Already have event " + eventId + " in limited " +
-                             "sync - not resetting");
+                    debuglog("Already have event " + eventId + " in limited " + "sync - not resetting");
                     limited = false;
 
                     // we might still be missing some of the events before i;
                     // we don't want to be adding them to the end of the
                     // timeline because that would put them out of order.
                     timelineEvents.splice(0, i);
 
                     // XXX: there's a problem here if the skipped part of the
@@ -765,317 +1149,424 @@ SyncApi.prototype._processSyncResponse =
                     // need to wind stateEvents forward over the events we're
                     // skipping.
 
                     break;
                 }
             }
 
             if (limited) {
-                // save the old 'next_batch' token as the
-                // forward-pagination token for the previously-active
-                // timeline.
-                room.currentState.paginationToken = syncToken;
                 self._deregisterStateListeners(room);
-                room.resetLiveTimeline(joinObj.timeline.prev_batch);
+                room.resetLiveTimeline(joinObj.timeline.prev_batch, self.opts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken);
 
                 // We have to assume any gap in any timeline is
                 // reason to stop incrementally tracking notifications and
                 // reset the timeline.
                 client.resetNotifTimelineSet();
 
                 self._registerStateListeners(room);
             }
         }
 
         self._processRoomEvents(room, stateEvents, timelineEvents);
 
-        // XXX: should we be adding ephemeralEvents to the timeline?
-        // It feels like that for symmetry with room.addAccountData()
-        // there should be a room.addEphemeralEvents() or similar.
-        room.addLiveEvents(ephemeralEvents);
+        // set summary after processing events,
+        // because it will trigger a name calculation
+        // which needs the room state to be up to date
+        if (joinObj.summary) {
+            room.setSummary(joinObj.summary);
+        }
+
+        // we deliberately don't add ephemeral events to the timeline
+        room.addEphemeralEvents(ephemeralEvents);
 
         // we deliberately don't add accountData to the timeline
         room.addAccountData(accountDataEvents);
 
-        room.recalculate(client.credentials.userId);
+        room.recalculate();
         if (joinObj.isBrandNewRoom) {
             client.store.storeRoom(room);
             client.emit("Room", room);
         }
-        stateEvents.forEach(function(e) { client.emit("event", e); });
-        timelineEvents.forEach(function(e) { client.emit("event", e); });
-        ephemeralEvents.forEach(function(e) { client.emit("event", e); });
-        accountDataEvents.forEach(function(e) { client.emit("event", e); });
+
+        self._processEventsForNotifs(room, timelineEvents);
+
+        async function processRoomEvent(e) {
+            client.emit("event", e);
+            if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) {
+                await self.opts.crypto.onCryptoEvent(e);
+            }
+            if (e.isState() && e.getType() === "im.vector.user_status") {
+                let user = client.store.getUser(e.getStateKey());
+                if (user) {
+                    user._unstable_updateStatusMessage(e);
+                } else {
+                    user = createNewUser(client, e.getStateKey());
+                    user._unstable_updateStatusMessage(e);
+                    client.store.storeUser(user);
+                }
+            }
+        }
+
+        await _bluebird2.default.mapSeries(stateEvents, processRoomEvent);
+        await _bluebird2.default.mapSeries(timelineEvents, processRoomEvent);
+        ephemeralEvents.forEach(function (e) {
+            client.emit("event", e);
+        });
+        accountDataEvents.forEach(function (e) {
+            client.emit("event", e);
+        });
+
+        room.updateMyMembership("join");
     });
 
     // Handle leaves (e.g. kicked rooms)
-    leaveRooms.forEach(function(leaveObj) {
-        var room = leaveObj.room;
-        var stateEvents =
-            self._mapSyncEventsFormat(leaveObj.state, room);
-        var timelineEvents =
-            self._mapSyncEventsFormat(leaveObj.timeline, room);
-        var accountDataEvents =
-            self._mapSyncEventsFormat(leaveObj.account_data);
+    leaveRooms.forEach(function (leaveObj) {
+        const room = leaveObj.room;
+        const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room);
+        const timelineEvents = self._mapSyncEventsFormat(leaveObj.timeline, room);
+        const accountDataEvents = self._mapSyncEventsFormat(leaveObj.account_data);
 
         self._processRoomEvents(room, stateEvents, timelineEvents);
         room.addAccountData(accountDataEvents);
 
-        room.recalculate(client.credentials.userId);
+        room.recalculate();
         if (leaveObj.isBrandNewRoom) {
             client.store.storeRoom(room);
             client.emit("Room", room);
         }
 
-        stateEvents.forEach(function(e) { client.emit("event", e); });
-        timelineEvents.forEach(function(e) { client.emit("event", e); });
-        accountDataEvents.forEach(function(e) { client.emit("event", e); });
+        self._processEventsForNotifs(room, timelineEvents);
+
+        stateEvents.forEach(function (e) {
+            client.emit("event", e);
+        });
+        timelineEvents.forEach(function (e) {
+            client.emit("event", e);
+        });
+        accountDataEvents.forEach(function (e) {
+            client.emit("event", e);
+        });
+
+        room.updateMyMembership("leave");
     });
 
     // update the notification timeline, if appropriate.
     // we only do this for live events, as otherwise we can't order them sanely
     // in the timeline relative to ones paginated in by /notifications.
     // XXX: we could fix this by making EventTimeline support chronological
     // ordering... but it doesn't, right now.
-    if (syncToken && this._notifEvents.length) {
-        this._notifEvents.sort(function(a, b) {
+    if (syncEventData.oldSyncToken && this._notifEvents.length) {
+        this._notifEvents.sort(function (a, b) {
             return a.getTs() - b.getTs();
         });
-        this._notifEvents.forEach(function(event) {
+        this._notifEvents.forEach(function (event) {
             client.getNotifTimelineSet().addLiveEvent(event);
         });
     }
+
+    // Handle device list updates
+    if (data.device_lists) {
+        if (this.opts.crypto) {
+            await this.opts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
+        } else {
+            // FIXME if we *don't* have a crypto module, we still need to
+            // invalidate the device lists. But that would require a
+            // substantial bit of rework :/.
+        }
+    }
+
+    // Handle one_time_keys_count
+    if (this.opts.crypto && data.device_one_time_keys_count) {
+        const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0;
+        this.opts.crypto.updateOneTimeKeyCount(currentCount);
+    }
 };
 
 /**
  * Starts polling the connectivity check endpoint
  * @param {number} delay How long to delay until the first poll.
  *        defaults to a short, randomised interval (to prevent
  *        tightlooping if /versions succeeds but /sync etc. fail).
- * @return {promise}
+ * @return {promise} which resolves once the connection returns
  */
-SyncApi.prototype._startKeepAlives = function(delay) {
+SyncApi.prototype._startKeepAlives = function (delay) {
     if (delay === undefined) {
         delay = 2000 + Math.floor(Math.random() * 5000);
     }
 
     if (this._keepAliveTimer !== null) {
         clearTimeout(this._keepAliveTimer);
     }
-    var self = this;
+    const self = this;
     if (delay > 0) {
-        self._keepAliveTimer = setTimeout(
-            self._pokeKeepAlive.bind(self),
-            delay
-        );
+        self._keepAliveTimer = setTimeout(self._pokeKeepAlive.bind(self), delay);
     } else {
         self._pokeKeepAlive();
     }
     if (!this._connectionReturnedDefer) {
-        this._connectionReturnedDefer = q.defer();
+        this._connectionReturnedDefer = _bluebird2.default.defer();
     }
     return this._connectionReturnedDefer.promise;
 };
 
 /**
+ * Make a dummy call to /_matrix/client/versions, to see if the HS is
+ * reachable.
  *
+ * On failure, schedules a call back to itself. On success, resolves
+ * this._connectionReturnedDefer.
+ *
+ * @param {bool} connDidFail True if a connectivity failure has been detected. Optional.
  */
-SyncApi.prototype._pokeKeepAlive = function() {
-    var self = this;
+SyncApi.prototype._pokeKeepAlive = function (connDidFail) {
+    if (connDidFail === undefined) connDidFail = false;
+    const self = this;
     function success() {
         clearTimeout(self._keepAliveTimer);
         if (self._connectionReturnedDefer) {
-            self._connectionReturnedDefer.resolve();
+            self._connectionReturnedDefer.resolve(connDidFail);
             self._connectionReturnedDefer = null;
         }
     }
 
-    this.client._http.request(
-        undefined, // callback
-        "GET", "/_matrix/client/versions",
-        undefined, // queryParams
-        undefined, // data
-        {
-            prefix: '',
-            localTimeoutMs: 15 * 1000,
-        }
-    ).done(function() {
+    this.client._http.request(undefined, // callback
+    "GET", "/_matrix/client/versions", undefined, // queryParams
+    undefined, // data
+    {
+        prefix: '',
+        localTimeoutMs: 15 * 1000
+    }).done(function () {
         success();
-    }, function(err) {
-        if (err.httpStatus == 400) {
+    }, function (err) {
+        if (err.httpStatus == 400 || err.httpStatus == 404) {
             // treat this as a success because the server probably just doesn't
             // support /versions: point is, we're getting a response.
             // We wait a short time though, just in case somehow the server
             // is in a mode where it 400s /versions responses and sync etc.
             // responses fail, this will mean we don't hammer in a loop.
             self._keepAliveTimer = setTimeout(success, 2000);
         } else {
-            self._keepAliveTimer = setTimeout(
-                self._pokeKeepAlive.bind(self),
-                5000 + Math.floor(Math.random() * 5000)
-            );
+            connDidFail = true;
+            self._keepAliveTimer = setTimeout(self._pokeKeepAlive.bind(self, connDidFail), 5000 + Math.floor(Math.random() * 5000));
             // A keepalive has failed, so we emit the
             // error state (whether or not this is the
             // first failure).
             // Note we do this after setting the timer:
             // this lets the unit tests advance the mock
-            // clock when the get the error.
+            // clock when they get the error.
             self._updateSyncState("ERROR", { error: err });
         }
     });
 };
 
 /**
+ * @param {Object} groupsSection Groups section object, eg. response.groups.invite
+ * @param {string} sectionName Which section this is ('invite', 'join' or 'leave')
+ */
+SyncApi.prototype._processGroupSyncEntry = function (groupsSection, sectionName) {
+    // Processes entries from 'groups' section of the sync stream
+    for (const groupId of Object.keys(groupsSection)) {
+        const groupInfo = groupsSection[groupId];
+        let group = this.client.store.getGroup(groupId);
+        const isBrandNew = group === null;
+        if (group === null) {
+            group = this.createGroup(groupId);
+        }
+        if (groupInfo.profile) {
+            group.setProfile(groupInfo.profile.name, groupInfo.profile.avatar_url);
+        }
+        if (groupInfo.inviter) {
+            group.setInviter({ userId: groupInfo.inviter });
+        }
+        group.setMyMembership(sectionName);
+        if (isBrandNew) {
+            // Now we've filled in all the fields, emit the Group event
+            this.client.emit("Group", group);
+        }
+    }
+};
+
+/**
  * @param {Object} obj
  * @return {Object[]}
  */
-SyncApi.prototype._mapSyncResponseToRoomArray = function(obj) {
+SyncApi.prototype._mapSyncResponseToRoomArray = function (obj) {
     // Maps { roomid: {stuff}, roomid: {stuff} }
     // to
     // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}]
-    var client = this.client;
-    var self = this;
-    return utils.keys(obj).map(function(roomId) {
-        var arrObj = obj[roomId];
-        var room = client.store.getRoom(roomId);
-        var isBrandNewRoom = false;
+    const client = this.client;
+    const self = this;
+    return utils.keys(obj).map(function (roomId) {
+        const arrObj = obj[roomId];
+        let room = client.store.getRoom(roomId);
+        let isBrandNewRoom = false;
         if (!room) {
             room = self.createRoom(roomId);
             isBrandNewRoom = true;
         }
         arrObj.room = room;
         arrObj.isBrandNewRoom = isBrandNewRoom;
         return arrObj;
     });
 };
 
 /**
  * @param {Object} obj
  * @param {Room} room
  * @return {MatrixEvent[]}
  */
-SyncApi.prototype._mapSyncEventsFormat = function(obj, room) {
+SyncApi.prototype._mapSyncEventsFormat = function (obj, room) {
     if (!obj || !utils.isArray(obj.events)) {
         return [];
     }
-    var mapper = this.client.getEventMapper();
-    return obj.events.map(function(e) {
+    const mapper = this.client.getEventMapper();
+    return obj.events.map(function (e) {
         if (room) {
             e.room_id = room.roomId;
         }
         return mapper(e);
     });
 };
 
 /**
  * @param {Room} room
  */
-SyncApi.prototype._resolveInvites = function(room) {
+SyncApi.prototype._resolveInvites = function (room) {
     if (!room || !this.opts.resolveInvitesToProfiles) {
         return;
     }
-    var client = this.client;
+    const client = this.client;
     // For each invited room member we want to give them a displayname/avatar url
     // if they have one (the m.room.member invites don't contain this).
-    room.getMembersWithMembership("invite").forEach(function(member) {
+    room.getMembersWithMembership("invite").forEach(function (member) {
         if (member._requestedProfileInfo) {
             return;
         }
         member._requestedProfileInfo = true;
         // try to get a cached copy first.
-        var user = client.getUser(member.userId);
-        var promise;
+        const user = client.getUser(member.userId);
+        let promise;
         if (user) {
-            promise = q({
+            promise = _bluebird2.default.resolve({
                 avatar_url: user.avatarUrl,
                 displayname: user.displayName
             });
-        }
-        else {
+        } else {
             promise = client.getProfileInfo(member.userId);
         }
-        promise.done(function(info) {
+        promise.done(function (info) {
             // slightly naughty by doctoring the invite event but this means all
             // the code paths remain the same between invite/join display name stuff
             // which is a worthy trade-off for some minor pollution.
-            var inviteEvent = member.events.member;
+            const inviteEvent = member.events.member;
             if (inviteEvent.getContent().membership !== "invite") {
                 // between resolving and now they have since joined, so don't clobber
                 return;
             }
             inviteEvent.getContent().avatar_url = info.avatar_url;
             inviteEvent.getContent().displayname = info.displayname;
             // fire listeners
             member.setMembershipEvent(inviteEvent, room.currentState);
-        }, function(err) {
+        }, function (err) {
             // OH WELL.
         });
     });
 };
 
 /**
  * @param {Room} room
  * @param {MatrixEvent[]} stateEventList A list of state events. This is the state
  * at the *START* of the timeline list if it is supplied.
- * @param {?MatrixEvent[]} timelineEventList A list of timeline events. Lower index
+ * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index
  * is earlier in time. Higher index is later.
  */
-SyncApi.prototype._processRoomEvents = function(room, stateEventList,
-                                                timelineEventList) {
-    timelineEventList = timelineEventList || [];
-    var client = this.client;
-    // "old" and "current" state are the same initially; they
-    // start diverging if the user paginates.
-    // We must deep copy otherwise membership changes in old state
-    // will leak through to current state!
-    var oldStateEvents = utils.map(
-        utils.deepCopy(
-            stateEventList.map(function(mxEvent) { return mxEvent.event; })
-        ), client.getEventMapper()
-    );
-    var stateEvents = stateEventList;
-
-    // set the state of the room to as it was before the timeline executes
-    //
-    // XXX: what if we've already seen (some of) the events in the timeline,
-    // and they modify some of the state set in stateEvents? In that case we'll
-    // end up with the state from stateEvents, instead of the more recent state
-    // from the timeline.
-    room.oldState.setStateEvents(oldStateEvents);
-    room.currentState.setStateEvents(stateEvents);
+SyncApi.prototype._processRoomEvents = function (room, stateEventList, timelineEventList) {
+    // If there are no events in the timeline yet, initialise it with
+    // the given state events
+    const liveTimeline = room.getLiveTimeline();
+    const timelineWasEmpty = liveTimeline.getEvents().length == 0;
+    if (timelineWasEmpty) {
+        // Passing these events into initialiseState will freeze them, so we need
+        // to compute and cache the push actions for them now, otherwise sync dies
+        // with an attempt to assign to read only property.
+        // XXX: This is pretty horrible and is assuming all sorts of behaviour from
+        // these functions that it shouldn't be. We should probably either store the
+        // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise
+        // find some solution where MatrixEvents are immutable but allow for a cache
+        // field.
+        for (const ev of stateEventList) {
+            this.client.getPushActionsForEvent(ev);
+        }
+        liveTimeline.initialiseState(stateEventList);
+    }
 
     this._resolveInvites(room);
 
     // recalculate the room name at this point as adding events to the timeline
     // may make notifications appear which should have the right name.
-    room.recalculate(this.client.credentials.userId);
+    // XXX: This looks suspect: we'll end up recalculating the room once here
+    // and then again after adding events (_processSyncResponse calls it after
+    // calling us) even if no state events were added. It also means that if
+    // one of the room events in timelineEventList is something that needs
+    // a recalculation (like m.room.name) we won't recalculate until we've
+    // finished adding all the events, which will cause the notification to have
+    // the old room name rather than the new one.
+    room.recalculate();
 
+    // If the timeline wasn't empty, we process the state events here: they're
+    // defined as updates to the state before the start of the timeline, so this
+    // starts to roll the state forward.
+    // XXX: That's what we *should* do, but this can happen if we were previously
+    // peeking in a room, in which case we obviously do *not* want to add the
+    // state events here onto the end of the timeline. Historically, the js-sdk
+    // has just set these new state events on the old and new state. This seems
+    // very wrong because there could be events in the timeline that diverge the
+    // state, in which case this is going to leave things out of sync. However,
+    // for now I think it;s best to behave the same as the code has done previously.
+    if (!timelineWasEmpty) {
+        // XXX: As above, don't do this...
+        //room.addLiveEvents(stateEventList || []);
+        // Do this instead...
+        room.oldState.setStateEvents(stateEventList || []);
+        room.currentState.setStateEvents(stateEventList || []);
+    }
+    // execute the timeline events. This will continue to diverge the current state
+    // if the timeline has any state events in it.
+    // This also needs to be done before running push rules on the events as they need
+    // to be decorated with sender etc.
+    room.addLiveEvents(timelineEventList || []);
+};
+
+/**
+ * Takes a list of timelineEvents and adds and adds to _notifEvents
+ * as appropriate.
+ * This must be called after the room the events belong to has been stored.
+ *
+ * @param {Room} room
+ * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index
+ * is earlier in time. Higher index is later.
+ */
+SyncApi.prototype._processEventsForNotifs = function (room, timelineEventList) {
     // gather our notifications into this._notifEvents
-    if (client.getNotifTimelineSet()) {
-        for (var i = 0; i < timelineEventList.length; i++) {
-            var pushActions = client.getPushActionsForEvent(timelineEventList[i]);
-            if (pushActions && pushActions.notify &&
-                pushActions.tweaks && pushActions.tweaks.highlight)
-            {
+    if (this.client.getNotifTimelineSet()) {
+        for (let i = 0; i < timelineEventList.length; i++) {
+            const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]);
+            if (pushActions && pushActions.notify && pushActions.tweaks && pushActions.tweaks.highlight) {
                 this._notifEvents.push(timelineEventList[i]);
             }
         }
     }
-
-    // execute the timeline events, this will begin to diverge the current state
-    // if the timeline has any state events in it.
-    room.addLiveEvents(timelineEventList);
 };
 
 /**
  * @return {string}
  */
-SyncApi.prototype._getGuestFilter = function() {
-    var guestRooms = this.client._guestRooms; // FIXME: horrible gut-wrenching
+SyncApi.prototype._getGuestFilter = function () {
+    const guestRooms = this.client._guestRooms; // FIXME: horrible gut-wrenching
     if (!guestRooms) {
         return "{}";
     }
     // we just need to specify the filter inline if we're a guest because guests
     // can't create filters.
     return JSON.stringify({
         room: {
             timeline: {
@@ -1085,54 +1576,34 @@ SyncApi.prototype._getGuestFilter = func
     });
 };
 
 /**
  * Sets the sync state and emits an event to say so
  * @param {String} newState The new state string
  * @param {Object} data Object of additional data to emit in the event
  */
-SyncApi.prototype._updateSyncState = function(newState, data) {
-    var old = this._syncState;
+SyncApi.prototype._updateSyncState = function (newState, data) {
+    const old = this._syncState;
     this._syncState = newState;
+    this._syncStateData = data;
     this.client.emit("sync", this._syncState, old, data);
 };
 
 /**
  * Event handler for the 'online' event
  * This event is generally unreliable and precise behaviour
  * varies between browsers, so we poll for connectivity too,
  * but this might help us reconnect a little faster.
  */
-SyncApi.prototype._onOnline = function() {
+SyncApi.prototype._onOnline = function () {
     debuglog("Browser thinks we are back online");
     this._startKeepAlives(0);
 };
 
 function createNewUser(client, userId) {
-    var user = new User(userId);
-    reEmit(client, user, [
-        "User.avatarUrl", "User.displayName", "User.presence",
-        "User.currentlyActive", "User.lastPresenceTs"
-    ]);
+    const user = new User(userId);
+    client.reEmitter.reEmit(user, ["User.avatarUrl", "User.displayName", "User.presence", "User.currentlyActive", "User.lastPresenceTs"]);
     return user;
 }
 
-function reEmit(reEmitEntity, emittableEntity, eventNames) {
-    utils.forEach(eventNames, function(eventName) {
-        // setup a listener on the entity (the Room, User, etc) for this event
-        emittableEntity.on(eventName, function() {
-            // take the args from the listener and reuse them, adding the
-            // event name to the arg list so it works with .emit()
-            // Transformation Example:
-            // listener on "foo" => function(a,b) { ... }
-            // Re-emit on "thing" => thing.emit("foo", a, b)
-            var newArgs = [eventName];
-            for (var i = 0; i < arguments.length; i++) {
-                newArgs.push(arguments[i]);
-            }
-            reEmitEntity.emit.apply(reEmitEntity, newArgs);
-        });
-    });
-}
-
 /** */
-module.exports = SyncApi;
+module.exports = SyncApi;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/timeline-window.js
@@ -12,35 +12,44 @@ distributed under the License is distrib
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 
 /** @module timeline-window */
 
-var q = require("q");
-var EventTimeline = require("./models/event-timeline");
+var _bluebird = require("bluebird");
+
+var _bluebird2 = _interopRequireDefault(_bluebird);
+
+var _logger = require("../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const EventTimeline = require("./models/event-timeline");
 
 /**
  * @private
  */
-var DEBUG = false;
+const DEBUG = false;
 
 /**
  * @private
  */
-var debuglog = DEBUG ? console.log.bind(console) : function() {};
+const debuglog = DEBUG ? _logger2.default.log.bind(_logger2.default) : function () {};
 
 /**
  * the number of times we ask the server for more events before giving up
  *
  * @private
  */
-var DEFAULT_PAGINATE_LOOP_LIMIT = 5;
+const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
 
 /**
  * Construct a TimelineWindow.
  *
  * <p>This abstracts the separate timelines in a Matrix {@link
  * module:models/room|Room} into a single iterable thing. It keeps track of
  * the start and endpoints of the window, which can be advanced with the help
  * of pagination requests.
@@ -86,93 +95,109 @@ function TimelineWindow(client, timeline
  * Initialise the window to point at a given event, or the live timeline
  *
  * @param {string} [initialEventId]   If given, the window will contain the
  *    given event
  * @param {number} [initialWindowSize = 20]   Size of the initial window
  *
  * @return {module:client.Promise}
  */
-TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) {
-    var self = this;
+TimelineWindow.prototype.load = function (initialEventId, initialWindowSize) {
+    const self = this;
     initialWindowSize = initialWindowSize || 20;
 
-    // given an EventTimeline, and an event index within it, initialise our
+    // given an EventTimeline, find the event we were looking for, and initialise our
     // fields so that the event in question is in the middle of the window.
-    var initFields = function(timeline, eventIndex) {
-        var endIndex = Math.min(timeline.getEvents().length,
-                                eventIndex + Math.ceil(initialWindowSize / 2));
-        var startIndex = Math.max(0, endIndex - initialWindowSize);
+    const initFields = function (timeline) {
+        let eventIndex;
+
+        const events = timeline.getEvents();
+
+        if (!initialEventId) {
+            // we were looking for the live timeline: initialise to the end
+            eventIndex = events.length;
+        } else {
+            for (let i = 0; i < events.length; i++) {
+                if (events[i].getId() == initialEventId) {
+                    eventIndex = i;
+                    break;
+                }
+            }
+
+            if (eventIndex === undefined) {
+                throw new Error("getEventTimeline result didn't include requested event");
+            }
+        }
+
+        const endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2));
+        const startIndex = Math.max(0, endIndex - initialWindowSize);
         self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
         self._end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
         self._eventCount = endIndex - startIndex;
     };
 
     // We avoid delaying the resolution of the promise by a reactor tick if
     // we already have the data we need, which is important to keep room-switching
     // feeling snappy.
     //
-    // TODO: ideally we'd spot getEventTimeline returning a resolved promise and
-    // skip straight to the find-event loop.
     if (initialEventId) {
-        return this._client.getEventTimeline(this._timelineSet, initialEventId)
-            .then(function(tl) {
-                // make sure that our window includes the event
-                for (var i = 0; i < tl.getEvents().length; i++) {
-                    if (tl.getEvents()[i].getId() == initialEventId) {
-                        initFields(tl, i);
-                        return;
-                    }
-                }
-                throw new Error("getEventTimeline result didn't include requested event");
-            });
+        const prom = this._client.getEventTimeline(this._timelineSet, initialEventId);
+
+        if (prom.isFulfilled()) {
+            initFields(prom.value());
+            return _bluebird2.default.resolve();
+        } else {
+            return prom.then(initFields);
+        }
     } else {
-        // start with the most recent events
-        var tl = this._timelineSet.getLiveTimeline();
-        initFields(tl, tl.getEvents().length);
-        return q();
+        const tl = this._timelineSet.getLiveTimeline();
+        initFields(tl);
+        return _bluebird2.default.resolve();
     }
 };
 
 /**
  * Check if this window can be extended
  *
  * <p>This returns true if we either have more events, or if we have a
  * pagination token which means we can paginate in that direction. It does not
  * necessarily mean that there are more events available in that direction at
  * this time.
  *
  * @param {string} direction   EventTimeline.BACKWARDS to check if we can
  *   paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
  *
  * @return {boolean} true if we can paginate in the given direction
  */
-TimelineWindow.prototype.canPaginate = function(direction) {
-    var tl;
+TimelineWindow.prototype.canPaginate = function (direction) {
+    let tl;
     if (direction == EventTimeline.BACKWARDS) {
         tl = this._start;
     } else if (direction == EventTimeline.FORWARDS) {
         tl = this._end;
     } else {
         throw new Error("Invalid direction '" + direction + "'");
     }
 
     if (!tl) {
         debuglog("TimelineWindow: no timeline yet");
         return false;
     }
 
     if (direction == EventTimeline.BACKWARDS) {
-        if (tl.index > tl.minIndex()) { return true; }
+        if (tl.index > tl.minIndex()) {
+            return true;
+        }
     } else {
-        if (tl.index < tl.maxIndex()) { return true; }
+        if (tl.index < tl.maxIndex()) {
+            return true;
+        }
     }
 
-    return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
-                   tl.timeline.getPaginationToken(direction));
+    return Boolean(tl.timeline.getNeighbouringTimeline(direction) || tl.timeline.getPaginationToken(direction));
 };
 
 /**
  * Attempt to extend the window
  *
  * @param {string} direction   EventTimeline.BACKWARDS to extend the window
  *    backwards (towards older events); EventTimeline.FORWARDS to go forwards.
  *
@@ -187,85 +212,82 @@ TimelineWindow.prototype.canPaginate = f
  *    we already know about.)
  *
  * @param {number} [requestLimit = 5] limit for the number of API requests we
  *    should make.
  *
  * @return {module:client.Promise} Resolves to a boolean which is true if more events
  *    were successfully retrieved.
  */
-TimelineWindow.prototype.paginate = function(direction, size, makeRequest,
-                                             requestLimit) {
+TimelineWindow.prototype.paginate = function (direction, size, makeRequest, requestLimit) {
     // Either wind back the message cap (if there are enough events in the
     // timeline to do so), or fire off a pagination request.
 
     if (makeRequest === undefined) {
         makeRequest = true;
     }
 
     if (requestLimit === undefined) {
         requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT;
     }
 
-    var tl;
+    let tl;
     if (direction == EventTimeline.BACKWARDS) {
         tl = this._start;
     } else if (direction == EventTimeline.FORWARDS) {
         tl = this._end;
     } else {
         throw new Error("Invalid direction '" + direction + "'");
     }
 
     if (!tl) {
         debuglog("TimelineWindow: no timeline yet");
-        return q(false);
+        return _bluebird2.default.resolve(false);
     }
 
     if (tl.pendingPaginate) {
         return tl.pendingPaginate;
     }
 
     // try moving the cap
-    var count = (direction == EventTimeline.BACKWARDS) ?
-        tl.retreat(size) : tl.advance(size);
+    const count = direction == EventTimeline.BACKWARDS ? tl.retreat(size) : tl.advance(size);
 
     if (count) {
         this._eventCount += count;
-        debuglog("TimelineWindow: increased cap by " + count +
-                 " (now " + this._eventCount + ")");
+        debuglog("TimelineWindow: increased cap by " + count + " (now " + this._eventCount + ")");
         // remove some events from the other end, if necessary
-        var excess = this._eventCount - this._windowLimit;
+        const excess = this._eventCount - this._windowLimit;
         if (excess > 0) {
             this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
         }
-        return q(true);
+        return _bluebird2.default.resolve(true);
     }
 
     if (!makeRequest || requestLimit === 0) {
         // todo: should we return something different to indicate that there
         // might be more events out there, but we haven't found them yet?
-        return q(false);
+        return _bluebird2.default.resolve(false);
     }
 
     // try making a pagination request
-    var token = tl.timeline.getPaginationToken(direction);
+    const token = tl.timeline.getPaginationToken(direction);
     if (!token) {
         debuglog("TimelineWindow: no token");
-        return q(false);
+        return _bluebird2.default.resolve(false);
     }
 
     debuglog("TimelineWindow: starting request");
-    var self = this;
+    const self = this;
 
-    var prom = this._client.paginateEventTimeline(tl.timeline, {
+    const prom = this._client.paginateEventTimeline(tl.timeline, {
         backwards: direction == EventTimeline.BACKWARDS,
         limit: size
-    }).finally(function() {
+    }).finally(function () {
         tl.pendingPaginate = null;
-    }).then(function(r) {
+    }).then(function (r) {
         debuglog("TimelineWindow: request completed with result " + r);
         if (!r) {
             // end of timeline
             return false;
         }
 
         // recurse to advance the index into the results.
         //
@@ -280,101 +302,95 @@ TimelineWindow.prototype.paginate = func
         // because it gives a bad user experience
         // (https://github.com/vector-im/vector-web/issues/1204).
         return self.paginate(direction, size, true, requestLimit - 1);
     });
     tl.pendingPaginate = prom;
     return prom;
 };
 
-
 /**
  * Remove `delta` events from the start or end of the timeline.
  *
  * @param {number}  delta           number of events to remove from the timeline
  * @param {boolean} startOfTimeline if events should be removed from the start
  *     of the timeline.
  */
-TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) {
-    var tl = startOfTimeline ? this._start : this._end;
+TimelineWindow.prototype.unpaginate = function (delta, startOfTimeline) {
+    const tl = startOfTimeline ? this._start : this._end;
 
     // sanity-check the delta
     if (delta > this._eventCount || delta < 0) {
-        throw new Error("Attemting to unpaginate " + delta + " events, but " +
-                        "only have " + this._eventCount + " in the timeline");
+        throw new Error("Attemting to unpaginate " + delta + " events, but " + "only have " + this._eventCount + " in the timeline");
     }
 
     while (delta > 0) {
-        var count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
+        const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
         if (count <= 0) {
             // sadness. This shouldn't be possible.
-            throw new Error(
-                "Unable to unpaginate any further, but still have " +
-                    this._eventCount + " events");
+            throw new Error("Unable to unpaginate any further, but still have " + this._eventCount + " events");
         }
 
         delta -= count;
         this._eventCount -= count;
-        debuglog("TimelineWindow.unpaginate: dropped " + count +
-                 " (now " + this._eventCount + ")");
+        debuglog("TimelineWindow.unpaginate: dropped " + count + " (now " + this._eventCount + ")");
     }
 };
 
-
 /**
  * Get a list of the events currently in the window
  *
  * @return {MatrixEvent[]} the events in the window
  */
-TimelineWindow.prototype.getEvents = function() {
+TimelineWindow.prototype.getEvents = function () {
     if (!this._start) {
         // not yet loaded
         return [];
     }
 
-    var result = [];
+    const result = [];
 
     // iterate through each timeline between this._start and this._end
     // (inclusive).
-    var timeline = this._start.timeline;
+    let timeline = this._start.timeline;
     while (true) {
-        var events = timeline.getEvents();
+        const events = timeline.getEvents();
 
         // For the first timeline in the chain, we want to start at
         // this._start.index. For the last timeline in the chain, we want to
         // stop before this._end.index. Otherwise, we want to copy all of the
         // events in the timeline.
         //
         // (Note that both this._start.index and this._end.index are relative
         // to their respective timelines' BaseIndex).
         //
-        var startIndex = 0, endIndex = events.length;
+        let startIndex = 0,
+            endIndex = events.length;
         if (timeline === this._start.timeline) {
             startIndex = this._start.index + timeline.getBaseIndex();
         }
         if (timeline === this._end.timeline) {
             endIndex = this._end.index + timeline.getBaseIndex();
         }
 
-        for (var i = startIndex; i < endIndex; i++) {
+        for (let i = startIndex; i < endIndex; i++) {
             result.push(events[i]);
         }
 
         // if we're not done, iterate to the next timeline.
         if (timeline === this._end.timeline) {
             break;
         } else {
             timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS);
         }
     }
 
     return result;
 };
 
-
 /**
  * a thing which contains a timeline reference, and an index into it.
  *
  * @constructor
  * @param {EventTimeline} timeline
  * @param {number} index
  * @private
  */
@@ -384,43 +400,43 @@ function TimelineIndex(timeline, index) 
     // the indexes are relative to BaseIndex, so could well be negative.
     this.index = index;
 }
 
 /**
  * @return {number} the minimum possible value for the index in the current
  *    timeline
  */
-TimelineIndex.prototype.minIndex = function() {
+TimelineIndex.prototype.minIndex = function () {
     return this.timeline.getBaseIndex() * -1;
 };
 
 /**
  * @return {number} the maximum possible value for the index in the current
  *    timeline (exclusive - ie, it actually returns one more than the index
  *    of the last element).
  */
-TimelineIndex.prototype.maxIndex = function() {
+TimelineIndex.prototype.maxIndex = function () {
     return this.timeline.getEvents().length - this.timeline.getBaseIndex();
 };
 
 /**
  * Try move the index forward, or into the neighbouring timeline
  *
  * @param {number} delta  number of events to advance by
  * @return {number} number of events successfully advanced by
  */
-TimelineIndex.prototype.advance = function(delta) {
+TimelineIndex.prototype.advance = function (delta) {
     if (!delta) {
         return 0;
     }
 
     // first try moving the index in the current timeline. See if there is room
     // to do so.
-    var cappedDelta;
+    let cappedDelta;
     if (delta < 0) {
         // we want to wind the index backwards.
         //
         // (this.minIndex() - this.index) is a negative number whose magnitude
         // is the amount of room we have to wind back the index in the current
         // timeline. We cap delta to this quantity.
         cappedDelta = Math.max(delta, this.minIndex() - this.index);
         if (cappedDelta < 0) {
@@ -438,18 +454,17 @@ TimelineIndex.prototype.advance = functi
             this.index += cappedDelta;
             return cappedDelta;
         }
     }
 
     // the index is already at the start/end of the current timeline.
     //
     // next see if there is a neighbouring timeline to switch to.
-    var neighbour = this.timeline.getNeighbouringTimeline(
-        delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
+    const neighbour = this.timeline.getNeighbouringTimeline(delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
     if (neighbour) {
         this.timeline = neighbour;
         if (delta < 0) {
             this.index = this.maxIndex();
         } else {
             this.index = this.minIndex();
         }
 
@@ -463,21 +478,21 @@ TimelineIndex.prototype.advance = functi
 };
 
 /**
  * Try move the index backwards, or into the neighbouring timeline
  *
  * @param {number} delta  number of events to retreat by
  * @return {number} number of events successfully retreated by
  */
-TimelineIndex.prototype.retreat = function(delta) {
+TimelineIndex.prototype.retreat = function (delta) {
     return this.advance(delta * -1) * -1;
 };
 
 /**
  * The TimelineWindow class.
  */
 module.exports.TimelineWindow = TimelineWindow;
 
 /**
  * The TimelineIndex class. exported here for unit testing.
  */
-module.exports.TimelineIndex = TimelineIndex;
+module.exports.TimelineIndex = TimelineIndex;
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/utils.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/utils.js
@@ -1,10 +1,11 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -14,144 +15,150 @@ See the License for the specific languag
 limitations under the License.
 */
 "use strict";
 /**
  * This is an internal module.
  * @module utils
  */
 
+const unhomoglyph = require('unhomoglyph');
+
 /**
  * Encode a dictionary of query parameters.
  * @param {Object} params A dict of key/values to encode e.g.
  * {"foo": "bar", "baz": "taz"}
  * @return {string} The encoded string e.g. foo=bar&baz=taz
  */
-module.exports.encodeParams = function(params) {
-    var qs = "";
-    for (var key in params) {
-        if (!params.hasOwnProperty(key)) { continue; }
-        qs += "&" + encodeURIComponent(key) + "=" +
-                encodeURIComponent(params[key]);
+module.exports.encodeParams = function (params) {
+    let qs = "";
+    for (const key in params) {
+        if (!params.hasOwnProperty(key)) {
+            continue;
+        }
+        qs += "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
     }
     return qs.substring(1);
 };
 
 /**
  * Encodes a URI according to a set of template variables. Variables will be
  * passed through encodeURIComponent.
  * @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'.
  * @param {Object} variables The key/value pairs to replace the template
  * variables with. E.g. { "$bar": "baz" }.
  * @return {string} The result of replacing all template variables e.g. '/foo/baz'.
  */
-module.exports.encodeUri = function(pathTemplate, variables) {
-    for (var key in variables) {
-        if (!variables.hasOwnProperty(key)) { continue; }
-        pathTemplate = pathTemplate.replace(
-            key, encodeURIComponent(variables[key])
-        );
+module.exports.encodeUri = function (pathTemplate, variables) {
+    for (const key in variables) {
+        if (!variables.hasOwnProperty(key)) {
+            continue;
+        }
+        pathTemplate = pathTemplate.replace(key, encodeURIComponent(variables[key]));
     }
     return pathTemplate;
 };
 
 /**
  * Applies a map function to the given array.
  * @param {Array} array The array to apply the function to.
  * @param {Function} fn The function that will be invoked for each element in
  * the array with the signature <code>fn(element){...}</code>
  * @return {Array} A new array with the results of the function.
  */
-module.exports.map = function(array, fn) {
-    var results = new Array(array.length);
-    for (var i = 0; i < array.length; i++) {
+module.exports.map = function (array, fn) {
+    const results = new Array(array.length);
+    for (let i = 0; i < array.length; i++) {
         results[i] = fn(array[i]);
     }
     return results;
 };
 
 /**
  * Applies a filter function to the given array.
  * @param {Array} array The array to apply the function to.
  * @param {Function} fn The function that will be invoked for each element in
  * the array. It should return true to keep the element. The function signature
  * looks like <code>fn(element, index, array){...}</code>.
  * @return {Array} A new array with the results of the function.
  */
-module.exports.filter = function(array, fn) {
-    var results = [];
-    for (var i = 0; i < array.length; i++) {
+module.exports.filter = function (array, fn) {
+    const results = [];
+    for (let i = 0; i < array.length; i++) {
         if (fn(array[i], i, array)) {
             results.push(array[i]);
         }
     }
     return results;
 };
 
 /**
  * Get the keys for an object. Same as <code>Object.keys()</code>.
  * @param {Object} obj The object to get the keys for.
  * @return {string[]} The keys of the object.
  */
-module.exports.keys = function(obj) {
-    var keys = [];
-    for (var key in obj) {
-        if (!obj.hasOwnProperty(key)) { continue; }
+module.exports.keys = function (obj) {
+    const keys = [];
+    for (const key in obj) {
+        if (!obj.hasOwnProperty(key)) {
+            continue;
+        }
         keys.push(key);
     }
     return keys;
 };
 
 /**
  * Get the values for an object.
  * @param {Object} obj The object to get the values for.
  * @return {Array<*>} The values of the object.
  */
-module.exports.values = function(obj) {
-    var values = [];
-    for (var key in obj) {
-        if (!obj.hasOwnProperty(key)) { continue; }
+module.exports.values = function (obj) {
+    const values = [];
+    for (const key in obj) {
+        if (!obj.hasOwnProperty(key)) {
+            continue;
+        }
         values.push(obj[key]);
     }
     return values;
 };
 
 /**
  * Invoke a function for each item in the array.
  * @param {Array} array The array.
  * @param {Function} fn The function to invoke for each element. Has the
  * function signature <code>fn(element, index)</code>.
  */
-module.exports.forEach = function(array, fn) {
-    for (var i = 0; i < array.length; i++) {
+module.exports.forEach = function (array, fn) {
+    for (let i = 0; i < array.length; i++) {
         fn(array[i], i);
     }
 };
 
 /**
  * The findElement() method returns a value in the array, if an element in the array
  * satisfies (returns true) the provided testing function. Otherwise undefined
  * is returned.
  * @param {Array} array The array.
  * @param {Function} fn Function to execute on each value in the array, with the
  * function signature <code>fn(element, index, array)</code>
  * @param {boolean} reverse True to search in reverse order.
  * @return {*} The first value in the array which returns <code>true</code> for
  * the given function.
  */
-module.exports.findElement = function(array, fn, reverse) {
-    var i;
+module.exports.findElement = function (array, fn, reverse) {
+    let i;
     if (reverse) {
         for (i = array.length - 1; i >= 0; i--) {
             if (fn(array[i], i, array)) {
                 return array[i];
             }
         }
-    }
-    else {
+    } else {
         for (i = 0; i < array.length; i++) {
             if (fn(array[i], i, array)) {
                 return array[i];
             }
         }
     }
 };
 
@@ -160,123 +167,123 @@ module.exports.findElement = function(ar
  * satisfies (returns true) the provided testing function.
  * @param {Array} array The array.
  * @param {Function} fn Function to execute on each value in the array, with the
  * function signature <code>fn(element, index, array)</code>. Return true to
  * remove this element and break.
  * @param {boolean} reverse True to search in reverse order.
  * @return {boolean} True if an element was removed.
  */
-module.exports.removeElement = function(array, fn, reverse) {
-    var i;
-    var removed;
+module.exports.removeElement = function (array, fn, reverse) {
+    let i;
+    let removed;
     if (reverse) {
         for (i = array.length - 1; i >= 0; i--) {
             if (fn(array[i], i, array)) {
                 removed = array[i];
                 array.splice(i, 1);
                 return removed;
             }
         }
-    }
-    else {
+    } else {
         for (i = 0; i < array.length; i++) {
             if (fn(array[i], i, array)) {
                 removed = array[i];
                 array.splice(i, 1);
                 return removed;
             }
         }
     }
     return false;
 };
 
 /**
  * Checks if the given thing is a function.
  * @param {*} value The thing to check.
  * @return {boolean} True if it is a function.
  */
-module.exports.isFunction = function(value) {
+module.exports.isFunction = function (value) {
     return Object.prototype.toString.call(value) == "[object Function]";
 };
 
 /**
  * Checks if the given thing is an array.
  * @param {*} value The thing to check.
  * @return {boolean} True if it is an array.
  */
-module.exports.isArray = function(value) {
-    return Array.isArray ? Array.isArray(value) :
-        Boolean(value && value.constructor === Array);
+module.exports.isArray = function (value) {
+    return Array.isArray ? Array.isArray(value) : Boolean(value && value.constructor === Array);
 };
 
 /**
  * Checks that the given object has the specified keys.
  * @param {Object} obj The object to check.
  * @param {string[]} keys The list of keys that 'obj' must have.
  * @throws If the object is missing keys.
  */
-module.exports.checkObjectHasKeys = function(obj, keys) {
-    for (var i = 0; i < keys.length; i++) {
+module.exports.checkObjectHasKeys = function (obj, keys) {
+    for (let i = 0; i < keys.length; i++) {
         if (!obj.hasOwnProperty(keys[i])) {
             throw new Error("Missing required key: " + keys[i]);
         }
     }
 };
 
 /**
  * Checks that the given object has no extra keys other than the specified ones.
  * @param {Object} obj The object to check.
  * @param {string[]} allowedKeys The list of allowed key names.
  * @throws If there are extra keys.
  */
-module.exports.checkObjectHasNoAdditionalKeys = function(obj, allowedKeys) {
-    for (var key in obj) {
-        if (!obj.hasOwnProperty(key)) { continue; }
+module.exports.checkObjectHasNoAdditionalKeys = function (obj, allowedKeys) {
+    for (const key in obj) {
+        if (!obj.hasOwnProperty(key)) {
+            continue;
+        }
         if (allowedKeys.indexOf(key) === -1) {
             throw new Error("Unknown key: " + key);
         }
     }
 };
 
 /**
  * Deep copy the given object. The object MUST NOT have circular references and
  * MUST NOT have functions.
  * @param {Object} obj The object to deep copy.
  * @return {Object} A copy of the object without any references to the original.
  */
-module.exports.deepCopy = function(obj) {
+module.exports.deepCopy = function (obj) {
     return JSON.parse(JSON.stringify(obj));
 };
 
 /**
  * Compare two objects for equality. The objects MUST NOT have circular references.
  *
  * @param {Object} x The first object to compare.
  * @param {Object} y The second object to compare.
  *
  * @return {boolean} true if the two objects are equal
  */
-var deepCompare = module.exports.deepCompare = function(x, y) {
+const deepCompare = module.exports.deepCompare = function (x, y) {
     // Inspired by
     // http://stackoverflow.com/questions/1068834/object-comparison-in-javascript#1144249
 
     // Compare primitives and functions.
     // Also check if both arguments link to the same object.
     if (x === y) {
         return true;
     }
 
     if (typeof x !== typeof y) {
         return false;
     }
 
     // special-case NaN (since NaN !== NaN)
     if (typeof x === 'number' && isNaN(x) && isNaN(y)) {
-         return true;
+        return true;
     }
 
     // special-case null (since typeof null == 'object', but null.constructor
     // throws)
     if (x === null || y === null) {
         return x === y;
     }
 
@@ -296,36 +303,37 @@ var deepCompare = module.exports.deepCom
     }
 
     // the object algorithm works for Array, but it's sub-optimal.
     if (x instanceof Array) {
         if (x.length !== y.length) {
             return false;
         }
 
-        for (var i = 0; i < x.length; i++) {
+        for (let i = 0; i < x.length; i++) {
             if (!deepCompare(x[i], y[i])) {
                 return false;
             }
         }
     } else {
         // disable jshint "The body of a for in should be wrapped in an if
         // statement"
         /* jshint -W089 */
 
         // check that all of y's direct keys are in x
-        var p;
+        let p;
         for (p in y) {
             if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
                 return false;
             }
         }
 
         // finally, compare each of x's keys with y
         for (p in y) {
+            // eslint-disable-line guard-for-in
             if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
                 return false;
             }
             if (!deepCompare(x[p], y[p])) {
                 return false;
             }
         }
     }
@@ -341,291 +349,279 @@ var deepCompare = module.exports.deepCom
  * This is approximately equivalent to ES6's Object.assign, except
  * that the latter doesn't copy inherited properties.
  *
  * @param {Object} target  The object that will receive new properties
  * @param {...Object} source  Objects from which to copy properties
  *
  * @return {Object} target
  */
-module.exports.extend = function() {
-    var target = arguments[0] || {};
-    // disable jshint "The body of a for in should be wrapped in an if
-    // statement"
-    /* jshint -W089 */
-    for (var i = 1; i < arguments.length; i++) {
-        var source = arguments[i];
-        for (var propName in source) {
+module.exports.extend = function () {
+    const target = arguments[0] || {};
+    for (let i = 1; i < arguments.length; i++) {
+        const source = arguments[i];
+        for (const propName in source) {
+            // eslint-disable-line guard-for-in
             target[propName] = source[propName];
         }
     }
-    /* jshint +W089 */
     return target;
 };
 
 /**
  * Run polyfills to add Array.map and Array.filter if they are missing.
  */
-module.exports.runPolyfills = function() {
+module.exports.runPolyfills = function () {
     //                Array.prototype.filter
     // ========================================================
     // SOURCE:
     // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
     if (!Array.prototype.filter) {
-      Array.prototype.filter = function(fun/*, thisArg*/) {
-
-        if (this === void 0 || this === null) {
-          throw new TypeError();
-        }
+        Array.prototype.filter = function (fun /*, thisArg*/) {
+            if (this === void 0 || this === null) {
+                throw new TypeError();
+            }
 
-        var t = Object(this);
-        var len = t.length >>> 0;
-        if (typeof fun !== 'function') {
-          throw new TypeError();
-        }
+            const t = Object(this);
+            const len = t.length >>> 0;
+            if (typeof fun !== 'function') {
+                throw new TypeError();
+            }
 
-        var res = [];
-        var thisArg = arguments.length >= 2 ? arguments[1] : void 0;
-        for (var i = 0; i < len; i++) {
-          if (i in t) {
-            var val = t[i];
+            const res = [];
+            const thisArg = arguments.length >= 2 ? arguments[1] : void 0;
+            for (let i = 0; i < len; i++) {
+                if (i in t) {
+                    const val = t[i];
 
-            // NOTE: Technically this should Object.defineProperty at
-            //       the next index, as push can be affected by
-            //       properties on Object.prototype and Array.prototype.
-            //       But that method's new, and collisions should be
-            //       rare, so use the more-compatible alternative.
-            if (fun.call(thisArg, val, i, t)) {
-              res.push(val);
+                    // NOTE: Technically this should Object.defineProperty at
+                    //       the next index, as push can be affected by
+                    //       properties on Object.prototype and Array.prototype.
+                    //       But that method's new, and collisions should be
+                    //       rare, so use the more-compatible alternative.
+                    if (fun.call(thisArg, val, i, t)) {
+                        res.push(val);
+                    }
+                }
             }
-          }
-        }
 
-        return res;
-      };
+            return res;
+        };
     }
 
     //                Array.prototype.map
     // ========================================================
     // SOURCE:
     // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
     // Production steps of ECMA-262, Edition 5, 15.4.4.19
     // Reference: http://es5.github.io/#x15.4.4.19
     if (!Array.prototype.map) {
-
-      Array.prototype.map = function(callback, thisArg) {
+        Array.prototype.map = function (callback, thisArg) {
+            let T, k;
 
-        var T, A, k;
-
-        if (this === null || this === undefined) {
-          throw new TypeError(' this is null or not defined');
-        }
+            if (this === null || this === undefined) {
+                throw new TypeError(' this is null or not defined');
+            }
 
-        // 1. Let O be the result of calling ToObject passing the |this|
-        //    value as the argument.
-        var O = Object(this);
+            // 1. Let O be the result of calling ToObject passing the |this|
+            //    value as the argument.
+            const O = Object(this);
 
-        // 2. Let lenValue be the result of calling the Get internal
-        //    method of O with the argument "length".
-        // 3. Let len be ToUint32(lenValue).
-        var len = O.length >>> 0;
+            // 2. Let lenValue be the result of calling the Get internal
+            //    method of O with the argument "length".
+            // 3. Let len be ToUint32(lenValue).
+            const len = O.length >>> 0;
 
-        // 4. If IsCallable(callback) is false, throw a TypeError exception.
-        // See: http://es5.github.com/#x9.11
-        if (typeof callback !== 'function') {
-          throw new TypeError(callback + ' is not a function');
-        }
+            // 4. If IsCallable(callback) is false, throw a TypeError exception.
+            // See: http://es5.github.com/#x9.11
+            if (typeof callback !== 'function') {
+                throw new TypeError(callback + ' is not a function');
+            }
 
-        // 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
-        if (arguments.length > 1) {
-          T = thisArg;
-        }
+            // 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
+            if (arguments.length > 1) {
+                T = thisArg;
+            }
 
-        // 6. Let A be a new array created as if by the expression new Array(len)
-        //    where Array is the standard built-in constructor with that name and
-        //    len is the value of len.
-        A = new Array(len);
+            // 6. Let A be a new array created as if by the expression new Array(len)
+            //    where Array is the standard built-in constructor with that name and
+            //    len is the value of len.
+            const A = new Array(len);
 
-        // 7. Let k be 0
-        k = 0;
+            // 7. Let k be 0
+            k = 0;
 
-        // 8. Repeat, while k < len
-        while (k < len) {
-
-          var kValue, mappedValue;
+            // 8. Repeat, while k < len
+            while (k < len) {
+                var kValue, mappedValue;
 
-          // a. Let Pk be ToString(k).
-          //   This is implicit for LHS operands of the in operator
-          // b. Let kPresent be the result of calling the HasProperty internal
-          //    method of O with argument Pk.
-          //   This step can be combined with c
-          // c. If kPresent is true, then
-          if (k in O) {
+                // a. Let Pk be ToString(k).
+                //   This is implicit for LHS operands of the in operator
+                // b. Let kPresent be the result of calling the HasProperty internal
+                //    method of O with argument Pk.
+                //   This step can be combined with c
+                // c. If kPresent is true, then
+                if (k in O) {
+                    // i. Let kValue be the result of calling the Get internal
+                    //    method of O with argument Pk.
+                    kValue = O[k];
 
-            // i. Let kValue be the result of calling the Get internal
-            //    method of O with argument Pk.
-            kValue = O[k];
-
-            // ii. Let mappedValue be the result of calling the Call internal
-            //     method of callback with T as the this value and argument
-            //     list containing kValue, k, and O.
-            mappedValue = callback.call(T, kValue, k, O);
+                    // ii. Let mappedValue be the result of calling the Call internal
+                    //     method of callback with T as the this value and argument
+                    //     list containing kValue, k, and O.
+                    mappedValue = callback.call(T, kValue, k, O);
 
-            // iii. Call the DefineOwnProperty internal method of A with arguments
-            // Pk, Property Descriptor
-            // { Value: mappedValue,
-            //   Writable: true,
-            //   Enumerable: true,
-            //   Configurable: true },
-            // and false.
+                    // iii. Call the DefineOwnProperty internal method of A with arguments
+                    // Pk, Property Descriptor
+                    // { Value: mappedValue,
+                    //   Writable: true,
+                    //   Enumerable: true,
+                    //   Configurable: true },
+                    // and false.
 
-            // In browsers that support Object.defineProperty, use the following:
-            // Object.defineProperty(A, k, {
-            //   value: mappedValue,
-            //   writable: true,
-            //   enumerable: true,
-            //   configurable: true
-            // });
+                    // In browsers that support Object.defineProperty, use the following:
+                    // Object.defineProperty(A, k, {
+                    //   value: mappedValue,
+                    //   writable: true,
+                    //   enumerable: true,
+                    //   configurable: true
+                    // });
 
-            // For best browser support, use the following:
-            A[k] = mappedValue;
-          }
-          // d. Increase k by 1.
-          k++;
-        }
+                    // For best browser support, use the following:
+                    A[k] = mappedValue;
+                }
+                // d. Increase k by 1.
+                k++;
+            }
 
-        // 9. return A
-        return A;
-      };
+            // 9. return A
+            return A;
+        };
     }
 
     //                Array.prototype.forEach
     // ========================================================
     // SOURCE:
-  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
+    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
     // Production steps of ECMA-262, Edition 5, 15.4.4.18
     // Reference: http://es5.github.io/#x15.4.4.18
     if (!Array.prototype.forEach) {
-
-      Array.prototype.forEach = function(callback, thisArg) {
-
-        var T, k;
+        Array.prototype.forEach = function (callback, thisArg) {
+            let T, k;
 
-        if (this === null || this === undefined) {
-          throw new TypeError(' this is null or not defined');
-        }
+            if (this === null || this === undefined) {
+                throw new TypeError(' this is null or not defined');
+            }
 
-        // 1. Let O be the result of calling ToObject passing the |this| value as the
-        // argument.
-        var O = Object(this);
+            // 1. Let O be the result of calling ToObject passing the |this| value as the
+            // argument.
+            const O = Object(this);
 
-        // 2. Let lenValue be the result of calling the Get internal method of O with the
-        // argument "length".
-        // 3. Let len be ToUint32(lenValue).
-        var len = O.length >>> 0;
+            // 2. Let lenValue be the result of calling the Get internal method of O with the
+            // argument "length".
+            // 3. Let len be ToUint32(lenValue).
+            const len = O.length >>> 0;
 
-        // 4. If IsCallable(callback) is false, throw a TypeError exception.
-        // See: http://es5.github.com/#x9.11
-        if (typeof callback !== "function") {
-          throw new TypeError(callback + ' is not a function');
-        }
+            // 4. If IsCallable(callback) is false, throw a TypeError exception.
+            // See: http://es5.github.com/#x9.11
+            if (typeof callback !== "function") {
+                throw new TypeError(callback + ' is not a function');
+            }
 
-        // 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
-        if (arguments.length > 1) {
-          T = thisArg;
-        }
+            // 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
+            if (arguments.length > 1) {
+                T = thisArg;
+            }
 
-        // 6. Let k be 0
-        k = 0;
+            // 6. Let k be 0
+            k = 0;
 
-        // 7. Repeat, while k < len
-        while (k < len) {
-
-          var kValue;
+            // 7. Repeat, while k < len
+            while (k < len) {
+                var kValue;
 
-          // a. Let Pk be ToString(k).
-          //   This is implicit for LHS operands of the in operator
-          // b. Let kPresent be the result of calling the HasProperty internal
-          //    method of O with
-          //    argument Pk.
-          //   This step can be combined with c
-          // c. If kPresent is true, then
-          if (k in O) {
+                // a. Let Pk be ToString(k).
+                //   This is implicit for LHS operands of the in operator
+                // b. Let kPresent be the result of calling the HasProperty internal
+                //    method of O with
+                //    argument Pk.
+                //   This step can be combined with c
+                // c. If kPresent is true, then
+                if (k in O) {
+                    // i. Let kValue be the result of calling the Get internal method of O with
+                    // argument Pk
+                    kValue = O[k];
 
-            // i. Let kValue be the result of calling the Get internal method of O with
-            // argument Pk
-            kValue = O[k];
-
-            // ii. Call the Call internal method of callback with T as the this value and
-            // argument list containing kValue, k, and O.
-            callback.call(T, kValue, k, O);
-          }
-          // d. Increase k by 1.
-          k++;
-        }
-        // 8. return undefined
-      };
+                    // ii. Call the Call internal method of callback with T as the this value and
+                    // argument list containing kValue, k, and O.
+                    callback.call(T, kValue, k, O);
+                }
+                // d. Increase k by 1.
+                k++;
+            }
+            // 8. return undefined
+        };
     }
 };
 
 /**
  * Inherit the prototype methods from one constructor into another. This is a
  * port of the Node.js implementation with an Object.create polyfill.
  *
  * @param {function} ctor Constructor function which needs to inherit the
  *     prototype.
  * @param {function} superCtor Constructor function to inherit prototype from.
  */
-module.exports.inherits = function(ctor, superCtor) {
+module.exports.inherits = function (ctor, superCtor) {
     // Add Object.create polyfill for IE8
     // Source:
     // https://developer.mozilla.org/en-US/docs/Web/JavaScript
     // /Reference/Global_Objects/Object/create#Polyfill
     if (typeof Object.create != 'function') {
-      // Production steps of ECMA-262, Edition 5, 15.2.3.5
-      // Reference: http://es5.github.io/#x15.2.3.5
-      Object.create = (function() {
-        // To save on memory, use a shared constructor
-        function Temp() {}
+        // Production steps of ECMA-262, Edition 5, 15.2.3.5
+        // Reference: http://es5.github.io/#x15.2.3.5
+        Object.create = function () {
+            // To save on memory, use a shared constructor
+            function Temp() {}
 
-        // make a safe reference to Object.prototype.hasOwnProperty
-        var hasOwn = Object.prototype.hasOwnProperty;
+            // make a safe reference to Object.prototype.hasOwnProperty
+            const hasOwn = Object.prototype.hasOwnProperty;
 
-        return function(O) {
-          // 1. If Type(O) is not Object or Null throw a TypeError exception.
-          if (typeof O != 'object') {
-            throw new TypeError('Object prototype may only be an Object or null');
-          }
+            return function (O) {
+                // 1. If Type(O) is not Object or Null throw a TypeError exception.
+                if (typeof O != 'object') {
+                    throw new TypeError('Object prototype may only be an Object or null');
+                }
 
-          // 2. Let obj be the result of creating a new object as if by the
-          //    expression new Object() where Object is the standard built-in
-          //    constructor with that name
-          // 3. Set the [[Prototype]] internal property of obj to O.
-          Temp.prototype = O;
-          var obj = new Temp();
-          Temp.prototype = null; // Let's not keep a stray reference to O...
+                // 2. Let obj be the result of creating a new object as if by the
+                //    expression new Object() where Object is the standard built-in
+                //    constructor with that name
+                // 3. Set the [[Prototype]] internal property of obj to O.
+                Temp.prototype = O;
+                const obj = new Temp();
+                Temp.prototype = null; // Let's not keep a stray reference to O...
 
-          // 4. If the argument Properties is present and not undefined, add
-          //    own properties to obj as if by calling the standard built-in
-          //    function Object.defineProperties with arguments obj and
-          //    Properties.
-          if (arguments.length > 1) {
-            // Object.defineProperties does ToObject on its first argument.
-            var Properties = Object(arguments[1]);
-            for (var prop in Properties) {
-              if (hasOwn.call(Properties, prop)) {
-                obj[prop] = Properties[prop];
-              }
-            }
-          }
+                // 4. If the argument Properties is present and not undefined, add
+                //    own properties to obj as if by calling the standard built-in
+                //    function Object.defineProperties with arguments obj and
+                //    Properties.
+                if (arguments.length > 1) {
+                    // Object.defineProperties does ToObject on its first argument.
+                    const Properties = Object(arguments[1]);
+                    for (const prop in Properties) {
+                        if (hasOwn.call(Properties, prop)) {
+                            obj[prop] = Properties[prop];
+                        }
+                    }
+                }
 
-          // 5. Return obj
-          return obj;
-        };
-      })();
+                // 5. Return obj
+                return obj;
+            };
+        }();
     }
     // END polyfill
 
     // Add util.inherits from Node.js
     // Source:
     // https://github.com/joyent/node/blob/master/lib/util.js
     // Copyright Joyent, Inc. and other Node contributors.
     //
@@ -652,8 +648,61 @@ module.exports.inherits = function(ctor,
         constructor: {
             value: ctor,
             enumerable: false,
             writable: true,
             configurable: true
         }
     });
 };
+
+/**
+ * Returns whether the given value is a finite number without type-coercion
+ *
+ * @param {*} value the value to test
+ * @return {boolean} whether or not value is a finite number without type-coercion
+ */
+module.exports.isNumber = function (value) {
+    return typeof value === 'number' && isFinite(value);
+};
+
+/**
+ * Removes zero width chars, diacritics and whitespace from the string
+ * Also applies an unhomoglyph on the string, to prevent similar looking chars
+ * @param {string} str the string to remove hidden characters from
+ * @return {string} a string with the hidden characters removed
+ */
+module.exports.removeHiddenChars = function (str) {
+    return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, ''));
+};
+const removeHiddenCharsRegex = /[\u200B-\u200D\u0300-\u036f\uFEFF\s]/g;
+
+function escapeRegExp(string) {
+    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+module.exports.escapeRegExp = escapeRegExp;
+
+module.exports.globToRegexp = function (glob, extended) {
+    extended = typeof extended === 'boolean' ? extended : true;
+    // From
+    // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
+    // Because micromatch is about 130KB with dependencies,
+    // and minimatch is not much better.
+    let pat = escapeRegExp(glob);
+    pat = pat.replace(/\\\*/g, '.*');
+    pat = pat.replace(/\?/g, '.');
+    if (extended) {
+        pat = pat.replace(/\\\[(!|)(.*)\\]/g, function (match, p1, p2, offset, string) {
+            const first = p1 && '^' || '';
+            const second = p2.replace(/\\-/, '-');
+            return '[' + first + second + ']';
+        });
+    }
+    return pat;
+};
+
+module.exports.ensureNoTrailingSlash = function (url) {
+    if (url && url.endsWith("/")) {
+        return url.substr(0, url.length - 1);
+    } else {
+        return url;
+    }
+};
\ No newline at end of file
--- a/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js
+++ b/chat/protocols/matrix/lib/matrix-sdk/webrtc/call.js
@@ -1,10 +1,11 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 New Vector Ltd
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 
     http://www.apache.org/licenses/LICENSE-2.0
 
 Unless required by applicable law or agreed to in writing, software
@@ -13,335 +14,358 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY 
 See the License for the specific language governing permissions and
 limitations under the License.
 */
 "use strict";
 /**
  * This is an internal module. See {@link createNewMatrixCall} for the public API.
  * @module webrtc/call
  */
-var utils = require("../utils");
-var EventEmitter = require("events").EventEmitter;
-var DEBUG = true;  // set true to enable console logging.
+
+var _logger = require("../../src/logger");
+
+var _logger2 = _interopRequireDefault(_logger);
+
+function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+const utils = require("../utils");
+const EventEmitter = require("events").EventEmitter;
+
+const DEBUG = true; // set true to enable console logging.
 
 // events: hangup, error(err), replaced(call), state(state, oldState)
 
 /**
+ * Fires whenever an error occurs when call.js encounters an issue with setting up the call.
+ * <p>
+ * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or
+ * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client
+ * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access
+ * to their audio/video hardware.
+ *
+ * @event module:webrtc/call~MatrixCall#"error"
+ * @param {Error} err The error raised by MatrixCall.
+ * @example
+ * matrixCall.on("error", function(err){
+ *   console.error(err.code, err);
+ * });
+ */
+
+/**
  * Construct a new Matrix Call.
  * @constructor
  * @param {Object} opts Config options.
  * @param {string} opts.roomId The room ID for this call.
  * @param {Object} opts.webRtc The WebRTC globals from the browser.
+ * @param {boolean} opts.forceTURN whether relay through TURN should be forced.
  * @param {Object} opts.URL The URL global.
  * @param {Array<Object>} opts.turnServers Optional. A list of TURN servers.
  * @param {MatrixClient} opts.client The Matrix Client instance to send events to.
  */
 function MatrixCall(opts) {
     this.roomId = opts.roomId;
     this.client = opts.client;
     this.webRtc = opts.webRtc;
+    this.forceTURN = opts.forceTURN;
     this.URL = opts.URL;
     // Array of Objects with urls, username, credential keys
     this.turnServers = opts.turnServers || [];
-    if (this.turnServers.length === 0) {
+    if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) {
         this.turnServers.push({
-            urls: [MatrixCall.FALLBACK_STUN_SERVER]
+            urls: [MatrixCall.FALLBACK_ICE_SERVER]
         });
     }
-    utils.forEach(this.turnServers, function(server) {
+    utils.forEach(this.turnServers, function (server) {
         utils.checkObjectHasKeys(server, ["urls"]);
     });
 
-    this.callId = "c" + new Date().getTime();
+    this.callId = "c" + new Date().getTime() + Math.random();
     this.state = 'fledgling';
     this.didConnect = false;
 
     // A queue for candidates waiting to go out.
     // We try to amalgamate candidates into a single candidate message where
     // possible
     this.candidateSendQueue = [];
     this.candidateSendTries = 0;
 
     // Lookup from opaque queue ID to a promise for media element operations that
     // need to be serialised into a given queue.  Store this per-MatrixCall on the
     // assumption that multiple matrix calls will never compete for control of the
     // same DOM elements.
     this.mediaPromises = Object.create(null);
 
     this.screenSharingStream = null;
+
+    this._answerContent = null;
 }
 /** The length of time a call can be ringing for. */
 MatrixCall.CALL_TIMEOUT_MS = 60000;
-/** The fallback server to use for STUN. */
-MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302';
+/** The fallback ICE server to use for STUN or TURN protocols. */
+MatrixCall.FALLBACK_ICE_SERVER = 'stun:turn.matrix.org';
 /** An error code when the local client failed to create an offer. */
 MatrixCall.ERR_LOCAL_OFFER_FAILED = "local_offer_failed";
 /**
  * An error code when there is no local mic/camera to use. This may be because
  * the hardware isn't plugged in, or the user has explicitly denied access.
  */
 MatrixCall.ERR_NO_USER_MEDIA = "no_user_media";
 
+/*
+ * Error code used when a call event failed to send
+ * because unknown devices were present in the room
+ */
+MatrixCall.ERR_UNKNOWN_DEVICES = "unknown_devices";
+
+/*
+ * Error code usewd when we fail to send the invite
+ * for some reason other than there being unknown devices
+ */
+MatrixCall.ERR_SEND_INVITE = "send_invite";
+
+/*
+ * Error code usewd when we fail to send the answer
+ * for some reason other than there being unknown devices
+ */
+MatrixCall.ERR_SEND_ANSWER = "send_answer";
+
 utils.inherits(MatrixCall, EventEmitter);
 
 /**
  * Place a voice call to this room.
  * @throws If you have not specified a listener for 'error' events.
  */
-MatrixCall.prototype.placeVoiceCall = function() {
+MatrixCall.prototype.placeVoiceCall = function () {
     debuglog("placeVoiceCall");
     checkForErrorListener(this);
     _placeCallWithConstraints(this, _getUserMediaVideoContraints('voice'));
     this.type = 'voice';
 };
 
 /**
  * Place a video call to this room.
  * @param {Element} remoteVideoElement a <code>&lt;video&gt;</code> DOM element
  * to render video to.
  * @param {Element} localVideoElement a <code>&lt;video&gt;</code> DOM element
  * to render the local camera preview.
  * @throws If you have not specified a listener for 'error' events.
  */
-MatrixCall.prototype.placeVideoCall = function(remoteVideoElement, localVideoElement) {
+MatrixCall.prototype.placeVideoCall = function (remoteVideoElement, localVideoElement) {
     debuglog("placeVideoCall");
     checkForErrorListener(this);
     this.localVideoElement = localVideoElement;
     this.remoteVideoElement = remoteVideoElement;
     _placeCallWithConstraints(this, _getUserMediaVideoContraints('video'));
     this.type = 'video';
     _tryPlayRemoteStream(this);
 };
 
 /**
  * Place a screen-sharing call to this room. This includes audio.
  * <b>This method is EXPERIMENTAL and subject to change without warning. It
- * only works in Google Chrome.</b>
+ * only works in Google Chrome and Firefox >= 44.</b>
  * @param {Element} remoteVideoElement a <code>&lt;video&gt;</code> DOM element
  * to render video to.
  * @param {Element} localVideoElement a <code>&lt;video&gt;</code> DOM element
  * to render the local camera preview.
  * @throws If you have not specified a listener for 'error' events.
  */
-MatrixCall.prototype.placeScreenSharingCall =
-    function(remoteVideoElement, localVideoElement)
-{
+MatrixCall.prototype.placeScreenSharingCall = function (remoteVideoElement, localVideoElement) {
     debuglog("placeScreenSharingCall");
     checkForErrorListener(this);
-    var screenConstraints = _getChromeScreenSharingConstraints(this);
+    const screenConstraints = _getScreenSharingConstraints(this);
     if (!screenConstraints) {
         return;
     }
     this.localVideoElement = localVideoElement;
     this.remoteVideoElement = remoteVideoElement;
-    var self = this;
-    this.webRtc.getUserMedia(screenConstraints, function(stream) {
+    const self = this;
+    this.webRtc.getUserMedia(screenConstraints, function (stream) {
         self.screenSharingStream = stream;
         debuglog("Got screen stream, requesting audio stream...");
-        var audioConstraints = _getUserMediaVideoContraints('voice');
+        const audioConstraints = _getUserMediaVideoContraints('voice');
         _placeCallWithConstraints(self, audioConstraints);
-    }, function(err) {
-        self.emit("error",
-            callError(
-                MatrixCall.ERR_NO_USER_MEDIA,
-                "Failed to get screen-sharing stream: " + err
-            )
-        );
+    }, function (err) {
+        self.emit("error", callError(MatrixCall.ERR_NO_USER_MEDIA, "Failed to get screen-sharing stream: " + err));
     });
     this.type = 'video';
     _tryPlayRemoteStream(this);
 };
 
 /**
  * Play the given HTMLMediaElement, serialising the operation into a chain
  * of promises to avoid racing access to the element
- * @param {Element} HTMLMediaElement element to play
+ * @param {Element} element HTMLMediaElement element to play
  * @param {string} queueId Arbitrary ID to track the chain of promises to be used
  */
-MatrixCall.prototype.playElement = function(element, queueId) {
-    console.log("queuing play on " + queueId + " and element " + element);
+MatrixCall.prototype.playElement = function (element, queueId) {
+    _logger2.default.log("queuing play on " + queueId + " and element " + element);
     // XXX: FIXME: Does this leak elements, given the old promises
     // may hang around and retain a reference to them?
     if (this.mediaPromises[queueId]) {
         // XXX: these promises can fail (e.g. by <video/> being unmounted whilst
         // pending receiving media to play - e.g. whilst switching between
         // rooms before answering an inbound call), and throw unhandled exceptions.
         // However, we should soldier on as best we can even if they fail, given
         // these failures may be non-fatal (as in the case of unmounts)
-        this.mediaPromises[queueId] =
-            this.mediaPromises[queueId].then(function() {
-                console.log("previous promise completed for " + queueId);
-                return element.play();
-            }, function() {
-                console.log("previous promise failed for " + queueId);
-                return element.play();
-            });
-    }
-    else {
+        this.mediaPromises[queueId] = this.mediaPromises[queueId].then(function () {
+            _logger2.default.log("previous promise completed for " + queueId);
+            return element.play();
+        }, function () {
+            _logger2.default.log("previous promise failed for " + queueId);
+            return element.play();
+        });
+    } else {
         this.mediaPromises[queueId] = element.play();
     }
 };
 
 /**
  * Pause the given HTMLMediaElement, serialising the operation into a chain
  * of promises to avoid racing access to the element
- * @param {Element} HTMLMediaElement element to pause
+ * @param {Element} element HTMLMediaElement element to pause
  * @param {string} queueId Arbitrary ID to track the chain of promises to be used
  */
-MatrixCall.prototype.pauseElement = function(element, queueId) {
-    console.log("queuing pause on " + queueId + " and element " + element);
+MatrixCall.prototype.pauseElement = function (element, queueId) {
+    _logger2.default.log("queuing pause on " + queueId + " and element " + element);
     if (this.mediaPromises[queueId]) {
-        this.mediaPromises[queueId] =
-            this.mediaPromises[queueId].then(function() {
-                console.log("previous promise completed for " + queueId);
-                return element.pause();
-            }, function() {
-                console.log("previous promise failed for " + queueId);
-                return element.pause();
-            });
-    }
-    else {
+        this.mediaPromises[queueId] = this.mediaPromises[queueId].then(function () {
+            _logger2.default.log("previous promise completed for " + queueId);
+            return element.pause();
+        }, function () {
+            _logger2.default.log("previous promise failed for " + queueId);
+            return element.pause();
+        });
+    } else {
         // pause doesn't actually return a promise, but do this for symmetry
         // and just in case it does in future.
         this.mediaPromises[queueId] = element.pause();
     }
 };
 
 /**
  * Assign the given HTMLMediaElement by setting the .src attribute on it,
  * serialising the operation into a chain of promises to avoid racing access
  * to the element
- * @param {Element} HTMLMediaElement element to pause
- * @param {string} src the src attribute value to assign to the element
+ * @param {Element} element HTMLMediaElement element to pause
+ * @param {MediaStream} srcObject the srcObject attribute value to assign to the element
  * @param {string} queueId Arbitrary ID to track the chain of promises to be used
  */
-MatrixCall.prototype.assignElement = function(element, src, queueId) {
-    console.log("queuing assign on " + queueId + " element " + element + " for " + src);
+MatrixCall.prototype.assignElement = function (element, srcObject, queueId) {
+    _logger2.default.log("queuing assign on " + queueId + " element " + element + " for " + srcObject);
     if (this.mediaPromises[queueId]) {
-        this.mediaPromises[queueId] =
-            this.mediaPromises[queueId].then(function() {
-                console.log("previous promise completed for " + queueId);
-                element.src = src;
-            }, function() {
-                console.log("previous promise failed for " + queueId);
-                element.src = src;
-            });
-    }
-    else {
-        element.src = src;
+        this.mediaPromises[queueId] = this.mediaPromises[queueId].then(function () {
+            _logger2.default.log("previous promise completed for " + queueId);
+            element.srcObject = srcObject;
+        }, function () {
+            _logger2.default.log("previous promise failed for " + queueId);
+            element.srcObject = srcObject;
+        });
+    } else {
+        element.srcObject = srcObject;
     }
 };
 
 /**
  * Retrieve the local <code>&lt;video&gt;</code> DOM element.
  * @return {Element} The dom element
  */
-MatrixCall.prototype.getLocalVideoElement = function() {
+MatrixCall.prototype.getLocalVideoElement = function () {
     return this.localVideoElement;
 };
 
 /**
  * Retrieve the remote <code>&lt;video&gt;</code> DOM element
  * used for playing back video capable streams.
  * @return {Element} The dom element
  */
-MatrixCall.prototype.getRemoteVideoElement = function() {
+MatrixCall.prototype.getRemoteVideoElement = function () {
     return this.remoteVideoElement;
 };
 
 /**
  * Retrieve the remote <code>&lt;audio&gt;</code> DOM element
  * used for playing back audio only streams.
  * @return {Element} The dom element
  */
-MatrixCall.prototype.getRemoteAudioElement = function() {
+MatrixCall.prototype.getRemoteAudioElement = function () {
     return this.remoteAudioElement;
 };
 
 /**
  * Set the local <code>&lt;video&gt;</code> DOM element. If this call is active,
  * video will be rendered to it immediately.
  * @param {Element} element The <code>&lt;video&gt;</code> DOM element.
  */
-MatrixCall.prototype.setLocalVideoElement = function(element) {
+MatrixCall.prototype.setLocalVideoElement = function (element) {
     this.localVideoElement = element;
 
     if (element && this.localAVStream && this.type === 'video') {
         element.autoplay = true;
-        this.assignElement(element,
-                           this.URL.createObjectURL(this.localAVStream),
-                           "localVideo");
+        this.assignElement(element, this.localAVStream, "localVideo");
         element.muted = true;
-        var self = this;
-        setTimeout(function() {
-            var vel = self.getLocalVideoElement();
+        const self = this;
+        setTimeout(function () {
+            const vel = self.getLocalVideoElement();
             if (vel.play) {
                 self.playElement(vel, "localVideo");
             }
         }, 0);
     }
 };
 
 /**
  * Set the remote <code>&lt;video&gt;</code> DOM element. If this call is active,
  * the first received video-capable stream will be rendered to it immediately.
  * @param {Element} element The <code>&lt;video&gt;</code> DOM element.
  */
-MatrixCall.prototype.setRemoteVideoElement = function(element) {
+MatrixCall.prototype.setRemoteVideoElement = function (element) {
     this.remoteVideoElement = element;
     _tryPlayRemoteStream(this);
 };
 
 /**
  * Set the remote <code>&lt;audio&gt;</code> DOM element. If this call is active,
  * the first received audio-only stream will be rendered to it immediately.
  * The audio will *not* be rendered from the remoteVideoElement.
  * @param {Element} element The <code>&lt;video&gt;</code> DOM element.
  */
-MatrixCall.prototype.setRemoteAudioElement = function(element) {
+MatrixCall.prototype.setRemoteAudioElement = function (element) {
     this.remoteVideoElement.muted = true;
     this.remoteAudioElement = element;
+    this.remoteAudioElement.muted = false;
     _tryPlayRemoteAudioStream(this);
 };
 
 /**
  * Configure this call from an invite event. Used by MatrixClient.
  * @protected
  * @param {MatrixEvent} event The m.call.invite event
  */
-MatrixCall.prototype._initWithInvite = function(event) {
+MatrixCall.prototype._initWithInvite = function (event) {
     this.msg = event.getContent();
     this.peerConn = _createPeerConnection(this);
-    var self = this;
+    const self = this;
     if (this.peerConn) {
-        this.peerConn.setRemoteDescription(
-            new this.webRtc.RtcSessionDescription(this.msg.offer),
-            hookCallback(self, self._onSetRemoteDescriptionSuccess),
-            hookCallback(self, self._onSetRemoteDescriptionError)
-        );
+        this.peerConn.setRemoteDescription(new this.webRtc.RtcSessionDescription(this.msg.offer), hookCallback(self, self._onSetRemoteDescriptionSuccess), hookCallback(self, self._onSetRemoteDescriptionError));
     }
     setState(this, 'ringing');
     this.direction = 'inbound';
 
     // firefox and OpenWebRTC's RTCPeerConnection doesn't add streams until it
     // starts getting media on them so we need to figure out whether a video
     // channel has been offered by ourselves.
-    if (
-        this.msg.offer &&
-        this.msg.offer.sdp &&
-        this.msg.offer.sdp.indexOf('m=video') > -1
-    ) {
+    if (this.msg.offer && this.msg.offer.sdp && this.msg.offer.sdp.indexOf('m=video') > -1) {
         this.type = 'video';
-    }
-    else {
+    } else {
         this.type = 'voice';
     }
 
     if (event.getAge()) {
-        setTimeout(function() {
+        setTimeout(function () {
             if (self.state == 'ringing') {
                 debuglog("Call invite has expired. Hanging up.");
                 self.hangupParty = 'remote'; // effectively
                 setState(self, 'ended');
                 stopAllMedia(self);
                 if (self.peerConn.signalingState != 'closed') {
                     self.peerConn.close();
                 }
@@ -351,334 +375,366 @@ MatrixCall.prototype._initWithInvite = f
     }
 };
 
 /**
  * Configure this call from a hangup event. Used by MatrixClient.
  * @protected
  * @param {MatrixEvent} event The m.call.hangup event
  */
-MatrixCall.prototype._initWithHangup = function(event) {
+MatrixCall.prototype._initWithHangup = function (event) {
     // perverse as it may seem, sometimes we want to instantiate a call with a
     // hangup message (because when getting the state of the room on load, events
     // come in reverse order and we want to remember that a call has been hung up)
     this.msg = event.getContent();
     setState(this, 'ended');
 };
 
 /**
  * Answer a call.
  */
-MatrixCall.prototype.answer = function() {
+MatrixCall.prototype.answer = function () {
     debuglog("Answering call %s of type %s", this.callId, this.type);
-    var self = this;
+    const self = this;
+
+    if (self._answerContent) {
+        self._sendAnswer();
+        return;
+    }
 
     if (!this.localAVStream && !this.waitForLocalAVStream) {
-        this.webRtc.getUserMedia(
-            _getUserMediaVideoContraints(this.type),
-            hookCallback(self, self._gotUserMediaForAnswer),
-            hookCallback(self, self._getUserMediaFailed)
-        );
+        this.webRtc.getUserMedia(_getUserMediaVideoContraints(this.type), hookCallback(self, self._maybeGotUserMediaForAnswer), hookCallback(self, self._maybeGotUserMediaForAnswer));
         setState(this, 'wait_local_media');
     } else if (this.localAVStream) {
-        this._gotUserMediaForAnswer(this.localAVStream);
+        this._maybeGotUserMediaForAnswer(this.localAVStream);
     } else if (this.waitForLocalAVStream) {
         setState(this, 'wait_local_media');
     }
 };
 
 /**
  * Replace this call with a new call, e.g. for glare resolution. Used by
  * MatrixClient.
  * @protected
  * @param {MatrixCall} newCall The new call.
  */
-MatrixCall.prototype._replacedBy = function(newCall) {
+MatrixCall.prototype._replacedBy = function (newCall) {
     debuglog(this.callId + " being replaced by " + newCall.callId);
     if (this.state == 'wait_local_media') {
         debuglog("Telling new call to wait for local media");
         newCall.waitForLocalAVStream = true;
     } else if (this.state == 'create_offer') {
         debuglog("Handing local stream to new call");
-        newCall._gotUserMediaForAnswer(this.localAVStream);
-        delete(this.localAVStream);
+        newCall._maybeGotUserMediaForAnswer(this.localAVStream);
+        delete this.localAVStream;
     } else if (this.state == 'invite_sent') {
         debuglog("Handing local stream to new call");
-        newCall._gotUserMediaForAnswer(this.localAVStream);
-        delete(this.localAVStream);
+        newCall._maybeGotUserMediaForAnswer(this.localAVStream);
+        delete this.localAVStream;
     }
     newCall.localVideoElement = this.localVideoElement;
     newCall.remoteVideoElement = this.remoteVideoElement;
     newCall.remoteAudioElement = this.remoteAudioElement;
     this.successor = newCall;
     this.emit("replaced", newCall);
     this.hangup(true);
 };
 
 /**
  * Hangup a call.
  * @param {string} reason The reason why the call is being hung up.
  * @param {boolean} suppressEvent True to suppress emitting an event.
  */
-MatrixCall.prototype.hangup = function(reason, suppressEvent) {
+MatrixCall.prototype.hangup = function (reason, suppressEvent) {
+    if (this.state == 'ended') return;
+
     debuglog("Ending call " + this.callId);
     terminate(this, "local", reason, !suppressEvent);
-    var content = {
+    const content = {
         version: 0,
         call_id: this.callId,
         reason: reason
     };
     sendEvent(this, 'm.call.hangup', content);
 };
 
 /**
  * Set whether the local video preview should be muted or not.
  * @param {boolean} muted True to mute the local video.
  */
-MatrixCall.prototype.setLocalVideoMuted = function(muted) {
+MatrixCall.prototype.setLocalVideoMuted = function (muted) {
     if (!this.localAVStream) {
         return;
     }
     setTracksEnabled(this.localAVStream.getVideoTracks(), !muted);
 };
 
 /**
  * Check if local video is muted.
  *
  * If there are multiple video tracks, <i>all</i> of the tracks need to be muted
  * for this to return true. This means if there are no video tracks, this will
  * return true.
  * @return {Boolean} True if the local preview video is muted, else false
  * (including if the call is not set up yet).
  */
-MatrixCall.prototype.isLocalVideoMuted = function() {
+MatrixCall.prototype.isLocalVideoMuted = function () {
     if (!this.localAVStream) {
         return false;
     }
     return !isTracksEnabled(this.localAVStream.getVideoTracks());
 };
 
 /**
  * Set whether the microphone should be muted or not.
  * @param {boolean} muted True to mute the mic.
  */
-MatrixCall.prototype.setMicrophoneMuted = function(muted) {
+MatrixCall.prototype.setMicrophoneMuted = function (muted) {
     if (!this.localAVStream) {
         return;
     }
     setTracksEnabled(this.localAVStream.getAudioTracks(), !muted);
 };
 
 /**
  * Check if the microphone is muted.
  *
  * If there are multiple audio tracks, <i>all</i> of the tracks need to be muted
  * for this to return true. This means if there are no audio tracks, this will
  * return true.
  * @return {Boolean} True if the mic is muted, else false (including if the call
  * is not set up yet).
  */
-MatrixCall.prototype.isMicrophoneMuted = function() {
+MatrixCall.prototype.isMicrophoneMuted = function () {
     if (!this.localAVStream) {
         return false;
     }
     return !isTracksEnabled(this.localAVStream.getAudioTracks());
 };
 
 /**
  * Internal
  * @private
  * @param {Object} stream
  */
-MatrixCall.prototype._gotUserMediaForInvite = function(stream) {
+MatrixCall.prototype._maybeGotUserMediaForInvite = function (stream) {
     if (this.successor) {
-        this.successor._gotUserMediaForAnswer(stream);
+        this.successor._maybeGotUserMediaForAnswer(stream);
         return;
     }
     if (this.state == 'ended') {
         return;
     }
-    debuglog("_gotUserMediaForInvite -> " + this.type);
-    var self = this;
-    var videoEl = this.getLocalVideoElement();
+    debuglog("_maybeGotUserMediaForInvite -> " + this.type);
+    const self = this;
 
-    if (videoEl && this.type == 'video') {
-        videoEl.autoplay = true;
-        if (this.screenSharingStream) {
-            debuglog("Setting screen sharing stream to the local video element");
-            this.assignElement(videoEl,
-                   this.URL.createObjectURL(this.screenSharingStream),
-                   "localVideo");
+    const error = stream;
+    const constraints = {
+        'mandatory': {
+            'OfferToReceiveAudio': true,
+            'OfferToReceiveVideo': self.type === 'video'
         }
-        else {
-            this.assignElement(videoEl,
-                   this.URL.createObjectURL(stream),
-                   "localVideo");
+    };
+    if (stream instanceof MediaStream) {
+        const videoEl = this.getLocalVideoElement();
+
+        if (videoEl && this.type == 'video') {
+            videoEl.autoplay = true;
+            if (this.screenSharingStream) {
+                debuglog("Setting screen sharing stream to the local video" + " element");
+                this.assignElement(videoEl, this.screenSharingStream, "localVideo");
+            } else {
+                this.assignElement(videoEl, stream, "localVideo");
+            }
+            videoEl.muted = true;
+            setTimeout(function () {
+                const vel = self.getLocalVideoElement();
+                if (vel.play) {
+                    self.playElement(vel, "localVideo");
+                }
+            }, 0);
         }
-        videoEl.muted = true;
-        setTimeout(function() {
-            var vel = self.getLocalVideoElement();
-            if (vel.play) {
-                self.playElement(vel, "localVideo");
-            }
-        }, 0);
+
+        if (this.screenSharingStream) {
+            this.screenSharingStream.addTrack(stream.getAudioTracks()[0]);
+            stream = this.screenSharingStream;
+        }
+
+        this.localAVStream = stream;
+        // why do we enable audio (and only audio) tracks here? -- matthew
+        setTracksEnabled(stream.getAudioTracks(), true);
+        this.peerConn = _createPeerConnection(this);
+        this.peerConn.addStream(stream);
+    } else if (error.name === 'PermissionDeniedError') {
+        debuglog('User denied access to camera/microphone.' + ' Or possibly you are using an insecure domain. Receiving only.');
+        this.peerConn = _createPeerConnection(this);
+    } else {
+        debuglog('Failed to getUserMedia: ' + error.name);
+        this._getUserMediaFailed(error);
+        return;
     }
 
-    this.localAVStream = stream;
-    // why do we enable audio (and only audio) tracks here? -- matthew
-    setTracksEnabled(stream.getAudioTracks(), true);
-    this.peerConn = _createPeerConnection(this);
-    this.peerConn.addStream(stream);
-    if (this.screenSharingStream) {
-        console.log("Adding screen-sharing stream to peer connection");
-        this.peerConn.addStream(this.screenSharingStream);
-        // let's use this for the local preview...
-        this.localAVStream = this.screenSharingStream;
-    }
-    this.peerConn.createOffer(
-        hookCallback(self, self._gotLocalOffer),
-        hookCallback(self, self._getLocalOfferFailed)
-    );
+    this.peerConn.createOffer(hookCallback(self, self._gotLocalOffer), hookCallback(self, self._getLocalOfferFailed), constraints);
     setState(self, 'create_offer');
 };
 
+MatrixCall.prototype._sendAnswer = function (stream) {
+    sendEvent(this, 'm.call.answer', this._answerContent).then(() => {
+        setState(this, 'connecting');
+        // If this isn't the first time we've tried to send the answer,
+        // we may have candidates queued up, so send them now.
+        _sendCandidateQueue(this);
+    }).catch(error => {
+        // We've failed to answer: back to the ringing state
+        setState(this, 'ringing');
+        this.client.cancelPendingEvent(error.event);
+
+        let code = MatrixCall.ERR_SEND_ANSWER;
+        let message = "Failed to send answer";
+        if (error.name == 'UnknownDeviceError') {
+            code = MatrixCall.ERR_UNKNOWN_DEVICES;
+            message = "Unknown devices present in the room";
+        }
+        this.emit("error", callError(code, message));
+        throw error;
+    });
+};
+
 /**
  * Internal
  * @private
  * @param {Object} stream
  */
-MatrixCall.prototype._gotUserMediaForAnswer = function(stream) {
-    var self = this;
+MatrixCall.prototype._maybeGotUserMediaForAnswer = function (stream) {
+    const self = this;
     if (self.state == 'ended') {
         return;
     }
-    var localVidEl = self.getLocalVideoElement();
+
+    const error = stream;
+    if (stream instanceof MediaStream) {
+        const localVidEl = self.getLocalVideoElement();
 
-    if (localVidEl && self.type == 'video') {
-        localVidEl.autoplay = true;
-        this.assignElement(localVidEl,
-               this.URL.createObjectURL(stream),
-               "localVideo");
-        localVidEl.muted = true;
-        setTimeout(function() {
-            var vel = self.getLocalVideoElement();
-            if (vel.play) {
-                self.playElement(vel, "localVideo");
-            }
-        }, 0);
+        if (localVidEl && self.type == 'video') {
+            localVidEl.autoplay = true;
+            this.assignElement(localVidEl, stream, "localVideo");
+            localVidEl.muted = true;
+            setTimeout(function () {
+                const vel = self.getLocalVideoElement();
+                if (vel.play) {
+                    self.playElement(vel, "localVideo");
+                }
+            }, 0);
+        }
+
+        self.localAVStream = stream;
+        setTracksEnabled(stream.getAudioTracks(), true);
+        self.peerConn.addStream(stream);
+    } else if (error.name === 'PermissionDeniedError') {
+        debuglog('User denied access to camera/microphone.' + ' Or possibly you are using an insecure domain. Receiving only.');
+    } else {
+        debuglog('Failed to getUserMedia: ' + error.name);
+        this._getUserMediaFailed(error);
+        return;
     }
 
-    self.localAVStream = stream;
-    setTracksEnabled(stream.getAudioTracks(), true);
-    self.peerConn.addStream(stream);
-
-    var constraints = {
+    const constraints = {
         'mandatory': {
             'OfferToReceiveAudio': true,
-            'OfferToReceiveVideo': self.type == 'video'
+            'OfferToReceiveVideo': self.type === 'video'
         }
     };
-    self.peerConn.createAnswer(function(description) {
-        debuglog("Created answer: " + description);
-        self.peerConn.setLocalDescription(description, function() {
-            var content = {
+    self.peerConn.createAnswer(function (description) {
+        debuglog("Created answer: ", description);
+        self.peerConn.setLocalDescription(description, function () {
+            self._answerContent = {
                 version: 0,
                 call_id: self.callId,
                 answer: {
                     sdp: self.peerConn.localDescription.sdp,
                     type: self.peerConn.localDescription.type
                 }
             };
-            sendEvent(self, 'm.call.answer', content);
-            setState(self, 'connecting');
-        }, function() {
+            self._sendAnswer();
+        }, function () {
             debuglog("Error setting local description!");
         }, constraints);
-    }, function(err) {
+    }, function (err) {
         debuglog("Failed to create answer: " + err);
     });
     setState(self, 'create_answer');
 };
 
 /**
  * Internal
  * @private
  * @param {Object} event
  */
-MatrixCall.prototype._gotLocalIceCandidate = function(event) {
+MatrixCall.prototype._gotLocalIceCandidate = function (event) {
     if (event.candidate) {
-        debuglog(
-            "Got local ICE " + event.candidate.sdpMid + " candidate: " +
-            event.candidate.candidate
-        );
+        debuglog("Got local ICE " + event.candidate.sdpMid + " candidate: " + event.candidate.candidate);
+
+        if (this.state == 'ended') return;
+
         // As with the offer, note we need to make a copy of this object, not
         // pass the original: that broke in Chrome ~m43.
-        var c = {
+        const c = {
             candidate: event.candidate.candidate,
             sdpMid: event.candidate.sdpMid,
             sdpMLineIndex: event.candidate.sdpMLineIndex
         };
         sendCandidate(this, c);
     }
 };
 
 /**
  * Used by MatrixClient.
  * @protected
  * @param {Object} cand
  */
-MatrixCall.prototype._gotRemoteIceCandidate = function(cand) {
+MatrixCall.prototype._gotRemoteIceCandidate = function (cand) {
     if (this.state == 'ended') {
         //debuglog("Ignoring remote ICE candidate because call has ended");
         return;
     }
     debuglog("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
-    this.peerConn.addIceCandidate(
-        new this.webRtc.RtcIceCandidate(cand),
-        function() {},
-        function(e) {}
-    );
+    this.peerConn.addIceCandidate(new this.webRtc.RtcIceCandidate(cand), function () {}, function (e) {});
 };
 
 /**
  * Used by MatrixClient.
  * @protected
  * @param {Object} msg
  */
-MatrixCall.prototype._receivedAnswer = function(msg) {
+MatrixCall.prototype._receivedAnswer = function (msg) {
     if (this.state == 'ended') {
         return;
     }
 
-    var self = this;
-    this.peerConn.setRemoteDescription(
-        new this.webRtc.RtcSessionDescription(msg.answer),
-        hookCallback(self, self._onSetRemoteDescriptionSuccess),
-        hookCallback(self, self._onSetRemoteDescriptionError)
-    );
+    const self = this;
+    this.peerConn.setRemoteDescription(new this.webRtc.RtcSessionDescription(msg.answer), hookCallback(self, self._onSetRemoteDescriptionSuccess), hookCallback(self, self._onSetRemoteDescriptionError));
     setState(self, 'connecting');
 };
 
 /**
  * Internal
  * @private
  * @param {Object} description
  */
-MatrixCall.prototype._gotLocalOffer = function(description) {
-    var self = this;
-    debuglog("Created offer: " + description);
+MatrixCall.prototype._gotLocalOffer = function (description) {
+    const self = this;
+    debuglog("Created offer: ", description);
 
     if (self.state == 'ended') {
-        debuglog("Ignoring newly created offer on call ID " + self.callId +
-            " because the call has ended");
+        debuglog("Ignoring newly created offer on call ID " + self.callId + " because the call has ended");
         return;
     }
 
-    self.peerConn.setLocalDescription(description, function() {
-        var content = {
+    self.peerConn.setLocalDescription(description, function () {
+        const content = {
             version: 0,
             call_id: self.callId,
             // OpenWebRTC appears to add extra stuff (like the DTLS fingerprint)
             // to the description when setting it on the peerconnection.
             // According to the spec it should only add ICE
             // candidates. Any ICE candidates that have already been generated
             // at this point will probably be sent both in the offer and separately.
             // Also, note that we have to make a new object here, copying the
@@ -686,596 +742,612 @@ MatrixCall.prototype._gotLocalOffer = fu
             // Passing the RTCSessionDescription object as-is doesn't work in
             // Chrome (as of about m43).
             offer: {
                 sdp: self.peerConn.localDescription.sdp,
                 type: self.peerConn.localDescription.type
             },
             lifetime: MatrixCall.CALL_TIMEOUT_MS
         };
-        sendEvent(self, 'm.call.invite', content);
+        sendEvent(self, 'm.call.invite', content).then(() => {
+            setState(self, 'invite_sent');
+            setTimeout(function () {
+                if (self.state == 'invite_sent') {
+                    self.hangup('invite_timeout');
+                }
+            }, MatrixCall.CALL_TIMEOUT_MS);
+        }).catch(error => {
+            let code = MatrixCall.ERR_SEND_INVITE;
+            let message = "Failed to send invite";
+            if (error.name == 'UnknownDeviceError') {
+                code = MatrixCall.ERR_UNKNOWN_DEVICES;
+                message = "Unknown devices present in the room";
+            }
 
-        setTimeout(function() {
-            if (self.state == 'invite_sent') {
-                self.hangup('invite_timeout');
-            }
-        }, MatrixCall.CALL_TIMEOUT_MS);
-        setState(self, 'invite_sent');
-    }, function() {
+            self.client.cancelPendingEvent(error.event);
+            terminate(self, "local", code, false);
+            self.emit("error", callError(code, message));
+            throw error;
+        });
+    }, function () {
         debuglog("Error setting local description!");
     });
 };
 
 /**
  * Internal
  * @private
  * @param {Object} error
  */
-MatrixCall.prototype._getLocalOfferFailed = function(error) {
-    this.emit(
-        "error",
-        callError(MatrixCall.ERR_LOCAL_OFFER_FAILED, "Failed to start audio for call!")
-    );
+MatrixCall.prototype._getLocalOfferFailed = function (error) {
+    this.emit("error", callError(MatrixCall.ERR_LOCAL_OFFER_FAILED, "Failed to start audio for call!"));
 };
 
 /**
  * Internal
  * @private
  * @param {Object} error
  */
-MatrixCall.prototype._getUserMediaFailed = function(error) {
-    this.emit(
-        "error",
-        callError(
-            MatrixCall.ERR_NO_USER_MEDIA,
-            "Couldn't start capturing media! Is your microphone set up and " +
-            "does this app have permission?"
-        )
-    );
-    this.hangup("user_media_failed");
+MatrixCall.prototype._getUserMediaFailed = function (error) {
+    terminate(this, "local", 'user_media_failed', false);
+    this.emit("error", callError(MatrixCall.ERR_NO_USER_MEDIA, "Couldn't start capturing media! Is your microphone set up and " + "does this app have permission?"));
 };
 
 /**
  * Internal
  * @private
  */
-MatrixCall.prototype._onIceConnectionStateChanged = function() {
+MatrixCall.prototype._onIceConnectionStateChanged = function () {
     if (this.state == 'ended') {
         return; // because ICE can still complete as we're ending the call
     }
-    debuglog(
-        "Ice connection state changed to: " + this.peerConn.iceConnectionState
-    );
+    debuglog("Ice connection state changed to: " + this.peerConn.iceConnectionState);
     // ideally we'd consider the call to be connected when we get media but
     // chrome doesn't implement any of the 'onstarted' events yet
-    if (this.peerConn.iceConnectionState == 'completed' ||
-            this.peerConn.iceConnectionState == 'connected') {
+    if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') {
         setState(this, 'connected');
         this.didConnect = true;
     } else if (this.peerConn.iceConnectionState == 'failed') {
         this.hangup('ice_failed');
     }
 };
 
 /**
  * Internal
  * @private
  */
-MatrixCall.prototype._onSignallingStateChanged = function() {
-    debuglog(
-        "call " + this.callId + ": Signalling state changed to: " +
-        this.peerConn.signalingState
-    );
+MatrixCall.prototype._onSignallingStateChanged = function () {
+    debuglog("call " + this.callId + ": Signalling state changed to: " + this.peerConn.signalingState);
 };
 
 /**
  * Internal
  * @private
  */
-MatrixCall.prototype._onSetRemoteDescriptionSuccess = function() {
+MatrixCall.prototype._onSetRemoteDescriptionSuccess = function () {
     debuglog("Set remote description");
 };
 
 /**
  * Internal
  * @private
  * @param {Object} e
  */
-MatrixCall.prototype._onSetRemoteDescriptionError = function(e) {
+MatrixCall.prototype._onSetRemoteDescriptionError = function (e) {
     debuglog("Failed to set remote description" + e);
 };
 
 /**
  * Internal
  * @private
  * @param {Object} event
  */
-MatrixCall.prototype._onAddStream = function(event) {
+MatrixCall.prototype._onAddStream = function (event) {
     debuglog("Stream id " + event.stream.id + " added");
 
-    var s = event.stream;
+    const s = event.stream;
 
     if (s.getVideoTracks().length > 0) {
         this.type = 'video';
         this.remoteAVStream = s;
         this.remoteAStream = s;
     } else {
         this.type = 'voice';
         this.remoteAStream = s;
     }
 
-    var self = this;
-    forAllTracksOnStream(s, function(t) {
+    const self = this;
+    forAllTracksOnStream(s, function (t) {
         debuglog("Track id " + t.id + " added");
         // not currently implemented in chrome
         t.onstarted = hookCallback(self, self._onRemoteStreamTrackStarted);
     });
 
     if (event.stream.oninactive !== undefined) {
         event.stream.oninactive = hookCallback(self, self._onRemoteStreamEnded);
-    }
-    else {
+    } else {
         // onended is deprecated from Chrome 54
         event.stream.onended = hookCallback(self, self._onRemoteStreamEnded);
     }
 
     // not currently implemented in chrome
     event.stream.onstarted = hookCallback(self, self._onRemoteStreamStarted);
 
     if (this.type === 'video') {
         _tryPlayRemoteStream(this);
         _tryPlayRemoteAudioStream(this);
-    }
-    else {
+    } else {
         _tryPlayRemoteAudioStream(this);
     }
 };
 
 /**
  * Internal
  * @private
  * @param {Object} event
  */
-MatrixCall.prototype._onRemoteStreamStarted = function(event) {
+MatrixCall.prototype._onRemoteStreamStarted = function (event) {
     setState(this, 'connected');
 };
 
 /**
  * Internal
  * @private
  * @param {Object} event
  */
-MatrixCall.prototype._onRemoteStreamEnded = function(event) {
+MatrixCall.prototype._onRemoteStreamEnded = function (event) {
     debuglog("Remote stream ended");
     this.hangupParty = 'remote';
     setState(this, 'ended');
     stopAllMedia(this);
     if (this.peerConn.signalingState != 'closed') {
         this.peerConn.close();
     }
     this.emit("hangup", this);
 };
 
 /**
  * Internal
  * @private
  * @param {Object} event
  */
-MatrixCall.prototype._onRemoteStreamTrackStarted = function(event) {
+MatrixCall.prototype._onRemoteStreamTrackStarted = function (event) {
     setState(this, 'connected');
 };
 
 /**
  * Used by MatrixClient.
  * @protected
  * @param {Object} msg
  */
-MatrixCall.prototype._onHangupReceived = function(msg) {
+MatrixCall.prototype._onHangupReceived = function (msg) {
     debuglog("Hangup received");
     terminate(this, "remote", msg.reason, true);
 };
 
 /**
  * Used by MatrixClient.
  * @protected
  * @param {Object} msg
  */
-MatrixCall.prototype._onAnsweredElsewhere = function(msg) {
+MatrixCall.prototype._onAnsweredElsewhere = function (msg) {
     debuglog("Answered elsewhere");
     terminate(this, "remote", "answered_elsewhere", true);
 };
 
-var setTracksEnabled = function(tracks, enabled) {
-    for (var i = 0; i < tracks.length; i++) {
+const setTracksEnabled = function (tracks, enabled) {
+    for (let i = 0; i < tracks.length; i++) {
         tracks[i].enabled = enabled;
     }
 };
 
-var isTracksEnabled = function(tracks) {
-    for (var i = 0; i < tracks.length; i++) {
+const isTracksEnabled = function (tracks) {
+    for (let i = 0; i < tracks.length; i++) {
         if (tracks[i].enabled) {
             return true; // at least one track is enabled
         }
     }
     return false;
 };
 
-var setState = function(self, state) {
-    var oldState = self.state;
+const setState = function (self, state) {
+    const oldState = self.state;
     self.state = state;
     self.emit("state", state, oldState);
 };
 
 /**
  * Internal
  * @param {MatrixCall} self
  * @param {string} eventType
  * @param {Object} content
  * @return {Promise}
  */
-var sendEvent = function(self, eventType, content) {
+const sendEvent = function (self, eventType, content) {
     return self.client.sendEvent(self.roomId, eventType, content);
 };
 
-var sendCandidate = function(self, content) {
+const sendCandidate = function (self, content) {
     // Sends candidates with are sent in a special way because we try to amalgamate
     // them into one message
     self.candidateSendQueue.push(content);
+
+    // Don't send the ICE candidates yet if the call is in the ringing state: this
+    // means we tried to pick (ie. started generating candidates) and then failed to
+    // send the answer and went back to the ringing state. Queue up the candidates
+    // to send if we sucessfully send the answer.
+    if (self.state == 'ringing') return;
+
     if (self.candidateSendTries === 0) {
-        setTimeout(function() {
+        setTimeout(function () {
             _sendCandidateQueue(self);
         }, 100);
     }
 };
 
-var terminate = function(self, hangupParty, hangupReason, shouldEmit) {
+const terminate = function (self, hangupParty, hangupReason, shouldEmit) {
     if (self.getRemoteVideoElement()) {
         if (self.getRemoteVideoElement().pause) {
             self.pauseElement(self.getRemoteVideoElement(), "remoteVideo");
         }
-        self.assignElement(self.getRemoteVideoElement(), "", "remoteVideo");
+        self.assignElement(self.getRemoteVideoElement(), null, "remoteVideo");
     }
     if (self.getRemoteAudioElement()) {
         if (self.getRemoteAudioElement().pause) {
             self.pauseElement(self.getRemoteAudioElement(), "remoteAudio");
         }
-        self.assignElement(self.getRemoteAudioElement(), "", "remoteAudio");
+        self.assignElement(self.getRemoteAudioElement(), null, "remoteAudio");
     }
     if (self.getLocalVideoElement()) {
         if (self.getLocalVideoElement().pause) {
             self.pauseElement(self.getLocalVideoElement(), "localVideo");
         }
-        self.assignElement(self.getLocalVideoElement(), "", "localVideo");
+        self.assignElement(self.getLocalVideoElement(), null, "localVideo");
     }
     self.hangupParty = hangupParty;
     self.hangupReason = hangupReason;
     setState(self, 'ended');
     stopAllMedia(self);
     if (self.peerConn && self.peerConn.signalingState !== 'closed') {
         self.peerConn.close();
     }
     if (shouldEmit) {
         self.emit("hangup", self);
     }
 };
 
-var stopAllMedia = function(self) {
+const stopAllMedia = function (self) {
     debuglog("stopAllMedia (stream=%s)", self.localAVStream);
     if (self.localAVStream) {
-        forAllTracksOnStream(self.localAVStream, function(t) {
+        forAllTracksOnStream(self.localAVStream, function (t) {
             if (t.stop) {
                 t.stop();
             }
         });
         // also call stop on the main stream so firefox will stop sharing
         // the mic
         if (self.localAVStream.stop) {
             self.localAVStream.stop();
         }
     }
     if (self.screenSharingStream) {
-        forAllTracksOnStream(self.screenSharingStream, function(t) {
+        forAllTracksOnStream(self.screenSharingStream, function (t) {
             if (t.stop) {
                 t.stop();
             }
         });
         if (self.screenSharingStream.stop) {
             self.screenSharingStream.stop();
         }
     }
     if (self.remoteAVStream) {
-        forAllTracksOnStream(self.remoteAVStream, function(t) {
+        forAllTracksOnStream(self.remoteAVStream, function (t) {
             if (t.stop) {
                 t.stop();
             }
         });
     }
     if (self.remoteAStream) {
-        forAllTracksOnStream(self.remoteAStream, function(t) {
+        forAllTracksOnStream(self.remoteAStream, function (t) {
             if (t.stop) {
                 t.stop();
             }
         });
     }
 };
 
-var _tryPlayRemoteStream = function(self) {
+const _tryPlayRemoteStream = function (self) {
     if (self.getRemoteVideoElement() && self.remoteAVStream) {
-        var player = self.getRemoteVideoElement();
+        const player = self.getRemoteVideoElement();
         player.autoplay = true;
-        self.assignElement(player,
-                           self.URL.createObjectURL(self.remoteAVStream),
-                           "remoteVideo");
-        setTimeout(function() {
-            var vel = self.getRemoteVideoElement();
+        self.assignElement(player, self.remoteAVStream, "remoteVideo");
+        setTimeout(function () {
+            const vel = self.getRemoteVideoElement();
             if (vel.play) {
                 self.playElement(vel, "remoteVideo");
             }
             // OpenWebRTC does not support oniceconnectionstatechange yet
             if (self.webRtc.isOpenWebRTC()) {
                 setState(self, 'connected');
             }
         }, 0);
     }
 };
 
-var _tryPlayRemoteAudioStream = function(self) {
+const _tryPlayRemoteAudioStream = async function (self) {
     if (self.getRemoteAudioElement() && self.remoteAStream) {
-        var player = self.getRemoteAudioElement();
+        const player = self.getRemoteAudioElement();
+
+        // if audioOutput is non-default:
+        if (audioOutput) await player.setSinkId(audioOutput);
+
         player.autoplay = true;
-        self.assignElement(player,
-                           self.URL.createObjectURL(self.remoteAStream),
-                           "remoteAudio");
-        setTimeout(function() {
-            var ael = self.getRemoteAudioElement();
+        self.assignElement(player, self.remoteAStream, "remoteAudio");
+        setTimeout(function () {
+            const ael = self.getRemoteAudioElement();
             if (ael.play) {
                 self.playElement(ael, "remoteAudio");
             }
             // OpenWebRTC does not support oniceconnectionstatechange yet
             if (self.webRtc.isOpenWebRTC()) {
                 setState(self, 'connected');
             }
         }, 0);
     }
 };
 
-var checkForErrorListener = function(self) {
+const checkForErrorListener = function (self) {
     if (self.listeners("error").length === 0) {
-        throw new Error(
-            "You MUST attach an error listener using call.on('error', function() {})"
-        );
+        throw new Error("You MUST attach an error listener using call.on('error', function() {})");
     }
 };
 
-var callError = function(code, msg) {
-    var e = new Error(msg);
+const callError = function (code, msg) {
+    const e = new Error(msg);
     e.code = code;
     return e;
 };
 
-var debuglog = function() {
+const debuglog = function () {
     if (DEBUG) {
-        console.log.apply(console, arguments);
+        _logger2.default.log(...arguments);
     }
 };
 
-var _sendCandidateQueue = function(self) {
+const _sendCandidateQueue = function (self) {
     if (self.candidateSendQueue.length === 0) {
         return;
     }
 
-    var cands = self.candidateSendQueue;
+    const cands = self.candidateSendQueue;
     self.candidateSendQueue = [];
     ++self.candidateSendTries;
-    var content = {
+    const content = {
         version: 0,
         call_id: self.callId,
         candidates: cands
     };
     debuglog("Attempting to send " + cands.length + " candidates");
-    sendEvent(self, 'm.call.candidates', content).then(function() {
+    sendEvent(self, 'm.call.candidates', content).then(function () {
         self.candidateSendTries = 0;
         _sendCandidateQueue(self);
-    }, function(error) {
-        for (var i = 0; i < cands.length; i++) {
+    }, function (error) {
+        for (let i = 0; i < cands.length; i++) {
             self.candidateSendQueue.push(cands[i]);
         }
 
         if (self.candidateSendTries > 5) {
-            debuglog(
-                "Failed to send candidates on attempt %s. Giving up for now.",
-                self.candidateSendTries
-            );
+            debuglog("Failed to send candidates on attempt %s. Giving up for now.", self.candidateSendTries);
             self.candidateSendTries = 0;
             return;
         }
 
-        var delayMs = 500 * Math.pow(2, self.candidateSendTries);
+        const delayMs = 500 * Math.pow(2, self.candidateSendTries);
         ++self.candidateSendTries;
         debuglog("Failed to send candidates. Retrying in " + delayMs + "ms");
-        setTimeout(function() {
+        setTimeout(function () {
             _sendCandidateQueue(self);
         }, delayMs);
     });
 };
 
-var _placeCallWithConstraints = function(self, constraints) {
+const _placeCallWithConstraints = function (self, constraints) {
     self.client.callList[self.callId] = self;
-    self.webRtc.getUserMedia(
-        constraints,
-        hookCallback(self, self._gotUserMediaForInvite),
-        hookCallback(self, self._getUserMediaFailed)
-    );
+    self.webRtc.getUserMedia(constraints, hookCallback(self, self._maybeGotUserMediaForInvite), hookCallback(self, self._maybeGotUserMediaForInvite));
     setState(self, 'wait_local_media');
     self.direction = 'outbound';
     self.config = constraints;
 };
 
-var _createPeerConnection = function(self) {
-    var servers = self.turnServers;
-    if (self.webRtc.vendor === "mozilla") {
-        // modify turnServers struct to match what mozilla expects.
-        servers = [];
-        for (var i = 0; i < self.turnServers.length; i++) {
-            for (var j = 0; j < self.turnServers[i].urls.length; j++) {
-                servers.push({
-                    url: self.turnServers[i].urls[j],
-                    username: self.turnServers[i].username,
-                    credential: self.turnServers[i].credential
-                });
-            }
-        }
-    }
-
-    var pc = new self.webRtc.RtcPeerConnection({
-        iceServers: servers
+const _createPeerConnection = function (self) {
+    const pc = new self.webRtc.RtcPeerConnection({
+        iceTransportPolicy: self.forceTURN ? 'relay' : undefined,
+        iceServers: self.turnServers
     });
     pc.oniceconnectionstatechange = hookCallback(self, self._onIceConnectionStateChanged);
     pc.onsignalingstatechange = hookCallback(self, self._onSignallingStateChanged);
     pc.onicecandidate = hookCallback(self, self._gotLocalIceCandidate);
     pc.onaddstream = hookCallback(self, self._onAddStream);
     return pc;
 };
 
-var _getChromeScreenSharingConstraints = function(call) {
-    var screen = global.screen;
+const _getScreenSharingConstraints = function (call) {
+    const screen = global.screen;
     if (!screen) {
-        call.emit("error", callError(
-            MatrixCall.ERR_NO_USER_MEDIA,
-            "Couldn't determine screen sharing constaints."
-        ));
-        return;
-    }
-    // it won't work at all if you're not on HTTPS so whine whine whine
-    if (!global.window || global.window.location.protocol !== "https:") {
-        call.emit("error", callError(
-            MatrixCall.ERR_NO_USER_MEDIA,
-            "You need to be using HTTPS to place a screen-sharing call."
-        ));
+        call.emit("error", callError(MatrixCall.ERR_NO_USER_MEDIA, "Couldn't determine screen sharing constaints."));
         return;
     }
 
     return {
         video: {
+            mediaSource: 'screen',
             mandatory: {
                 chromeMediaSource: "screen",
                 chromeMediaSourceId: "" + Date.now(),
                 maxWidth: screen.width,
                 maxHeight: screen.height,
                 minFrameRate: 1,
                 maxFrameRate: 10
             }
         }
     };
 };
 
-var _getUserMediaVideoContraints = function(callType) {
+const _getUserMediaVideoContraints = function (callType) {
+    const isWebkit = !!global.window.navigator.webkitGetUserMedia;
+
     switch (callType) {
         case 'voice':
-            return ({audio: true, video: false});
+            return {
+                audio: {
+                    deviceId: audioInput ? { ideal: audioInput } : undefined
+                }, video: false
+            };
         case 'video':
-            return ({audio: true, video: {
-                mandatory: {
-                    minWidth: 640,
-                    maxWidth: 640,
-                    minHeight: 360,
-                    maxHeight: 360
+            return {
+                audio: {
+                    deviceId: audioInput ? { ideal: audioInput } : undefined
+                }, video: {
+                    deviceId: videoInput ? { ideal: videoInput } : undefined,
+                    /* We want 640x360.  Chrome will give it only if we ask exactly,
+                       FF refuses entirely if we ask exactly, so have to ask for ideal
+                       instead */
+                    width: isWebkit ? { exact: 640 } : { ideal: 640 },
+                    height: isWebkit ? { exact: 360 } : { ideal: 360 }
                 }
-            }});
+            };
     }
 };
 
-var hookCallback = function(call, fn) {
-    return function() {
+const hookCallback = function (call, fn) {
+    return function () {
         return fn.apply(call, arguments);
     };
 };
 
-var forAllVideoTracksOnStream = function(s, f) {
-    var tracks = s.getVideoTracks();
-    for (var i = 0; i < tracks.length; i++) {
+const forAllVideoTracksOnStream = function (s, f) {
+    const tracks = s.getVideoTracks();
+    for (let i = 0; i < tracks.length; i++) {
         f(tracks[i]);
     }
 };
 
-var forAllAudioTracksOnStream = function(s, f) {
-    var tracks = s.getAudioTracks();
-    for (var i = 0; i < tracks.length; i++) {
+const forAllAudioTracksOnStream = function (s, f) {
+    const tracks = s.getAudioTracks();
+    for (let i = 0; i < tracks.length; i++) {
         f(tracks[i]);
     }
 };
 
-var forAllTracksOnStream = function(s, f) {
+const forAllTracksOnStream = function (s, f) {
     forAllVideoTracksOnStream(s, f);
     forAllAudioTracksOnStream(s, f);
 };
 
 /** The MatrixCall class. */
 module.exports.MatrixCall = MatrixCall;
 
+let audioOutput;
+let audioInput;
+let videoInput;
+/**
+ * Set an audio output device to use for MatrixCalls
+ * @function
+ * @param {string=} deviceId the identifier for the device
+ * undefined treated as unset
+ */
+module.exports.setAudioOutput = function (deviceId) {
+    audioOutput = deviceId;
+};
+/**
+ * Set an audio input device to use for MatrixCalls
+ * @function
+ * @param {string=} deviceId the identifier for the device
+ * undefined treated as unset
+ */
+module.exports.setAudioInput = function (deviceId) {
+    audioInput = deviceId;
+};
+/**
+ * Set a video input device to use for MatrixCalls
+ * @function
+ * @param {string=} deviceId the identifier for the device
+ * undefined treated as unset
+ */
+module.exports.setVideoInput = function (deviceId) {
+    videoInput = deviceId;
+};
 
 /**
  * Create a new Matrix call for the browser.
  * @param {MatrixClient} client The client instance to use.
  * @param {string} roomId The room the call is in.
+ * @param {Object?} options DEPRECATED optional options map.
+ * @param {boolean} options.forceTURN DEPRECATED whether relay through TURN should be
+ * forced. This option is deprecated - use opts.forceTURN when creating the matrix client
+ * since it's only possible to set this option on outbound calls.
  * @return {MatrixCall} the call or null if the browser doesn't support calling.
  */
-module.exports.createNewMatrixCall = function(client, roomId) {
-    var w = global.window;
-    var doc = global.document;
+module.exports.createNewMatrixCall = function (client, roomId, options) {
+    const w = global.window;
+    const doc = global.document;
     if (!w || !doc) {
         return null;
     }
-    var webRtc = {};
-    webRtc.isOpenWebRTC = function() {
-        var scripts = doc.getElementById("script");
+    const webRtc = {};
+    webRtc.isOpenWebRTC = function () {
+        const scripts = doc.getElementById("script");
         if (!scripts || !scripts.length) {
             return false;
         }
-        for (var i = 0; i < scripts.length; i++) {
+        for (let i = 0; i < scripts.length; i++) {
             if (scripts[i].src.indexOf("owr.js") > -1) {
                 return true;
             }
         }
         return false;
     };
-    var getUserMedia = (
-        w.navigator.getUserMedia || w.navigator.webkitGetUserMedia ||
-        w.navigator.mozGetUserMedia
-    );
+    const getUserMedia = w.navigator.getUserMedia || w.navigator.webkitGetUserMedia || w.navigator.mozGetUserMedia;
     if (getUserMedia) {
-        webRtc.getUserMedia = function() {
+        webRtc.getUserMedia = function () {
             return getUserMedia.apply(w.navigator, arguments);
         };
     }
-    webRtc.RtcPeerConnection = (
-        w.RTCPeerConnection || w.webkitRTCPeerConnection || w.mozRTCPeerConnection
-    );
-    webRtc.RtcSessionDescription = (
-        w.RTCSessionDescription || w.webkitRTCSessionDescription ||
-        w.mozRTCSessionDescription
-    );
-    webRtc.RtcIceCandidate = (
-        w.RTCIceCandidate || w.webkitRTCIceCandidate || w.mozRTCIceCandidate
-    );
-    webRtc.vendor = null;
-    if (w.mozRTCPeerConnection) {
-        webRtc.vendor = "mozilla";
+
+    // Firefox throws on so little as accessing the RTCPeerConnection when operating in
+    // a secure mode. There's some information at https://bugzilla.mozilla.org/show_bug.cgi?id=1542616
+    // though the concern is that the browser throwing a SecurityError will brick the
+    // client creation process.
+    try {
+        webRtc.RtcPeerConnection = w.RTCPeerConnection || w.webkitRTCPeerConnection || w.mozRTCPeerConnection;
+        webRtc.RtcSessionDescription = w.RTCSessionDescription || w.webkitRTCSessionDescription || w.mozRTCSessionDescription;
+        webRtc.RtcIceCandidate = w.RTCIceCandidate || w.webkitRTCIceCandidate || w.mozRTCIceCandidate;
+        webRtc.vendor = null;
+        if (w.mozRTCPeerConnection) {
+            webRtc.vendor = "mozilla";
+        } else if (w.webkitRTCPeerConnection) {
+            webRtc.vendor = "webkit";
+        } else if (w.RTCPeerConnection) {
+            webRtc.vendor = "generic";
+        }
+    } catch (e) {
+        _logger2.default.error("Failed to set up WebRTC object: possible browser interference?");
+        _logger2.default.error(e);
+        return null;
     }
-    else if (w.webkitRTCPeerConnection) {
-        webRtc.vendor = "webkit";
-    }
-    else if (w.RTCPeerConnection) {
-        webRtc.vendor = "generic";
-    }
-    if (!webRtc.RtcIceCandidate || !webRtc.RtcSessionDescription ||
-            !webRtc.RtcPeerConnection || !webRtc.getUserMedia) {
+
+    if (!webRtc.RtcIceCandidate || !webRtc.RtcSessionDescription || !webRtc.RtcPeerConnection || !webRtc.getUserMedia) {
         return null; // WebRTC is not supported.
     }
-    var opts = {
+
+    const optionsForceTURN = options ? options.forceTURN : false;
+
+    const opts = {
         webRtc: webRtc,
         client: client,
         URL: w.URL,
         roomId: roomId,
-        turnServers: client.getTurnServers()
+        turnServers: client.getTurnServers(),
+        // call level options
+        forceTURN: client._forceTURN || optionsForceTURN
     };
     return new MatrixCall(opts);
-};
+};
\ No newline at end of file
--- a/chat/protocols/matrix/lib/moz.build
+++ b/chat/protocols/matrix/lib/moz.build
@@ -1,62 +1,97 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 # The Matrix SDK.
 EXTRA_JS_MODULES.matrix.matrix_sdk += [
+    'matrix-sdk/autodiscovery.js',
     'matrix-sdk/base-apis.js',
     'matrix-sdk/client.js',
+    'matrix-sdk/content-helpers.js',
     'matrix-sdk/content-repo.js',
+    'matrix-sdk/errors.js',
     'matrix-sdk/filter-component.js',
     'matrix-sdk/filter.js',
     'matrix-sdk/http-api.js',
+    'matrix-sdk/indexeddb-helpers.js',
+    'matrix-sdk/indexeddb-worker.js',
     'matrix-sdk/interactive-auth.js',
+    'matrix-sdk/logger.js',
     'matrix-sdk/matrix.js',
     'matrix-sdk/pushprocessor.js',
+    'matrix-sdk/randomstring.js',
     'matrix-sdk/realtime-callbacks.js',
+    'matrix-sdk/ReEmitter.js',
     'matrix-sdk/scheduler.js',
+    'matrix-sdk/service-types.js',
+    'matrix-sdk/sync-accumulator.js',
     'matrix-sdk/sync.js',
     'matrix-sdk/timeline-window.js',
     'matrix-sdk/utils.js',
 ]
 
 EXTRA_JS_MODULES.matrix.matrix_sdk.crypto += [
+    'matrix-sdk/crypto/backup_password.js',
     'matrix-sdk/crypto/deviceinfo.js',
+    'matrix-sdk/crypto/DeviceList.js',
     'matrix-sdk/crypto/index.js',
     'matrix-sdk/crypto/OlmDevice.js',
     'matrix-sdk/crypto/olmlib.js',
+    'matrix-sdk/crypto/OutgoingRoomKeyRequestManager.js',
+    'matrix-sdk/crypto/recoverykey.js',
+    'matrix-sdk/crypto/RoomList.js',
 ]
 
 EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.algorithms += [
     'matrix-sdk/crypto/algorithms/base.js',
     'matrix-sdk/crypto/algorithms/index.js',
     'matrix-sdk/crypto/algorithms/megolm.js',
     'matrix-sdk/crypto/algorithms/olm.js',
 ]
 
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.store += [
+    'matrix-sdk/crypto/store/base.js',
+    'matrix-sdk/crypto/store/indexeddb-crypto-store-backend.js',
+    'matrix-sdk/crypto/store/indexeddb-crypto-store.js',
+    'matrix-sdk/crypto/store/localStorage-crypto-store.js',
+    'matrix-sdk/crypto/store/memory-crypto-store.js',
+]
+
+EXTRA_JS_MODULES.matrix.matrix_sdk.crypto.verification += [
+    'matrix-sdk/crypto/verification/Base.js',
+    'matrix-sdk/crypto/verification/Error.js',
+    'matrix-sdk/crypto/verification/QRCode.js',
+    'matrix-sdk/crypto/verification/SAS.js',
+]
+
 EXTRA_JS_MODULES.matrix.matrix_sdk.models += [
     'matrix-sdk/models/event-context.js',
     'matrix-sdk/models/event-timeline-set.js',
     'matrix-sdk/models/event-timeline.js',
     'matrix-sdk/models/event.js',
+    'matrix-sdk/models/group.js',
+    'matrix-sdk/models/relations.js',
     'matrix-sdk/models/room-member.js',
     'matrix-sdk/models/room-state.js',
     'matrix-sdk/models/room-summary.js',
     'matrix-sdk/models/room.js',
     'matrix-sdk/models/search-result.js',
     'matrix-sdk/models/user.js',
 ]
 
 EXTRA_JS_MODULES.matrix.matrix_sdk.store += [
+    'matrix-sdk/store/indexeddb-local-backend.js',
+    'matrix-sdk/store/indexeddb-remote-backend.js',
+    'matrix-sdk/store/indexeddb-store-worker.js',
+    'matrix-sdk/store/indexeddb.js',
     'matrix-sdk/store/memory.js',
     'matrix-sdk/store/stub.js',
-    'matrix-sdk/store/webstorage.js',
 ]
 
 EXTRA_JS_MODULES.matrix.matrix_sdk.store.session += [
     'matrix-sdk/store/session/webstorage.js',
 ]
 
 EXTRA_JS_MODULES.matrix.matrix_sdk.webrtc += [
     'matrix-sdk/webrtc/call.js',
--- a/chat/protocols/matrix/matrix-sdk.jsm
+++ b/chat/protocols/matrix/matrix-sdk.jsm
@@ -24,101 +24,90 @@ this.EXPORTED_SYMBOLS = ["MatrixSDK"];
 //   keeps all references to utils.js next to each other).
 // * They're then ordered by source, with the bare name first, then prefixed by
 //   ., then prefixed by .., etc.
 let matrixPath = "resource:///modules/matrix/";
 let loader = Loader({
   paths: {
     // Matrix SDK files.
     "": matrixPath + "matrix_sdk/",
-    "./base-apis": matrixPath + "matrix_sdk/base-apis.js",
-    "./client": matrixPath + "matrix_sdk/client.js",
-    "./content-repo": matrixPath + "matrix_sdk/content-repo.js",
     "../content-repo": matrixPath + "matrix_sdk/content-repo.js",
-    "./filter": matrixPath + "matrix_sdk/filter.js",
-    "./filter-component": matrixPath + "matrix_sdk/filter-component.js",
-    "./http-api": matrixPath + "matrix_sdk/http-api.js",
-    "./interactive-auth": matrixPath + "matrix_sdk/interactive-auth.js",
-    "./pushprocessor": matrixPath + "matrix_sdk/pushprocessor.js",
-    "./realtime-callbacks": matrixPath + "matrix_sdk/realtime-callbacks.js",
-    "./scheduler": matrixPath + "matrix_sdk/scheduler.js",
-    "./sync": matrixPath + "matrix_sdk/sync.js",
-    "./timeline-window": matrixPath + "matrix_sdk/timeline-window.js",
-    "./utils": matrixPath + "matrix_sdk/utils.js",
+    "../../errors": matrixPath + "matrix_sdk/errors.js",
+    "../indexeddb-helpers": matrixPath + "matrix_sdk/indexeddb-helpers.js",
+    "../../indexeddb-helpers": matrixPath + "matrix_sdk/indexeddb-helpers.js",
+    "../logger": matrixPath + "matrix_sdk/logger.js",
+    "../../logger": matrixPath + "matrix_sdk/logger.js",
+    "../src/logger": matrixPath + "matrix_sdk/logger.js",
+    "../../src/logger": matrixPath + "matrix_sdk/logger.js",
+    "../../../src/logger": matrixPath + "matrix_sdk/logger.js",
+    "../randomstring": matrixPath + "matrix_sdk/randomstring.js",
+    "../ReEmitter": matrixPath + "matrix_sdk/ReEmitter.js",
+    "../sync-accumulator": matrixPath + "matrix_sdk/sync-accumulator.js",
     "../utils": matrixPath + "matrix_sdk/utils.js",
+    "../utils.js": matrixPath + "matrix_sdk/utils.js",
     "../../utils": matrixPath + "matrix_sdk/utils.js",
-    "./../../utils": matrixPath + "matrix_sdk/utils.js",
 
     // crypto
-    base: matrixPath + "matrix_sdk/crypto/algorithms/base.js",
-    "./base": matrixPath + "matrix_sdk/crypto/algorithms/base.js",
-    algorithms: matrixPath + "matrix_sdk/crypto/algorithms/index.js",
-    "./algorithms": matrixPath + "matrix_sdk/crypto/algorithms/index.js",
-    megolm: matrixPath + "matrix_sdk/crypto/algorithms/megolm.js",
-    "./megolm": matrixPath + "matrix_sdk/crypto/algorithms/megolm.js",
-    olm: matrixPath + "matrix_sdk/crypto/algorithms/olm.js",
-    "./olm": matrixPath + "matrix_sdk/crypto/algorithms/olm.js",
+    "crypto/backup_password": matrixPath + "matrix_sdk/crypto/backup_password.js",
     deviceinfo: matrixPath + "matrix_sdk/crypto/deviceinfo.js",
-    "./deviceinfo": matrixPath + "matrix_sdk/crypto/deviceinfo.js",
     "../deviceinfo": matrixPath + "matrix_sdk/crypto/deviceinfo.js",
+    DeviceList: matrixPath + "matrix_sdk/crypto/DeviceList.js",
+    "../DeviceList": matrixPath + "matrix_sdk/crypto/DeviceList.js",
     crypto: matrixPath + "matrix_sdk/crypto/index.js",
-    "./crypto": matrixPath + "matrix_sdk/crypto/index.js",
     olmlib: matrixPath + "matrix_sdk/crypto/olmlib.js",
-    "./olmlib": matrixPath + "matrix_sdk/crypto/olmlib.js",
     "../olmlib": matrixPath + "matrix_sdk/crypto/olmlib.js",
+    "crypto/olmlib": matrixPath + "matrix_sdk/crypto/olmlib.js",
     OlmDevice: matrixPath + "matrix_sdk/crypto/OlmDevice.js",
-    "./OlmDevice": matrixPath + "matrix_sdk/crypto/OlmDevice.js",
+    "crypto/recoverykey": matrixPath + "matrix_sdk/crypto/recoverykey.js",
+    OutgoingRoomKeyRequestManager: matrixPath + "matrix_sdk/crypto/OutgoingRoomKeyRequestManager.js",
+    "crypto/RoomList": matrixPath + "matrix_sdk/crypto/RoomList.js",
+
+    // crypto/algorithms
+    base: matrixPath + "matrix_sdk/crypto/algorithms/base.js",
+    algorithms: matrixPath + "matrix_sdk/crypto/algorithms/index.js",
+    megolm: matrixPath + "matrix_sdk/crypto/algorithms/megolm.js",
+    olm: matrixPath + "matrix_sdk/crypto/algorithms/olm.js",
+
+    // crypto/store
+    "store/indexeddb-crypto-store": matrixPath + "matrix_sdk/crypto/store/indexeddb-crypto-store.js",
+    "crypto/store/indexeddb-crypto-store": matrixPath + "matrix_sdk/crypto/store/indexeddb-crypto-store.js",
+    "crypto/store/indexeddb-crypto-store-backend": matrixPath + "matrix_sdk/crypto/store/indexeddb-crypto-store-backend.js",
+    "crypto/store/localStorage-crypto-store": matrixPath + "matrix_sdk/crypto/store/localStorage-crypto-store.js",
+    "crypto/store/memory-crypto-store": matrixPath + "matrix_sdk/crypto/store/memory-crypto-store.js",
+
+    // crypto/verification
+    Base: matrixPath + "matrix_sdk/crypto/verification/Base.js",
+    Error: matrixPath + "matrix_sdk/crypto/verification/Error.js",
+    "verification/Base": matrixPath + "matrix_sdk/crypto/verification/Base.js",
+    "verification/Error": matrixPath + "matrix_sdk/crypto/verification/Error.js",
+    "verification/QRCode": matrixPath + "matrix_sdk/crypto/verification/QRCode.js",
+    "verification/SAS": matrixPath + "matrix_sdk/crypto/verification/SAS.js",
 
     // models
-    "./event": matrixPath + "matrix_sdk/models/event.js",
-    "./models/event": matrixPath + "matrix_sdk/models/event.js",
     "../models/event": matrixPath + "matrix_sdk/models/event.js",
-    "./event-context": matrixPath + "matrix_sdk/models/event-content.js",
-    "./event-timeline": matrixPath + "matrix_sdk/models/event-timeline.js",
-    "./models/event-timeline": matrixPath + "matrix_sdk/models/event-timeline.js",
-    "./event-timeline-set": matrixPath + "matrix_sdk/models/event-timeline-set.js",
-    "./models/event-timeline-set": matrixPath + "matrix_sdk/models/event-timeline-set.js",
-    "./models/room": matrixPath + "matrix_sdk/models/room.js",
-    "../models/room": matrixPath + "matrix_sdk/models/room.js",
-    "./room-member": matrixPath + "matrix_sdk/models/room-member.js",
-    "./models/room-member": matrixPath + "matrix_sdk/models/room-member.js",
-    "./room-state": matrixPath + "matrix_sdk/models/room-state.js",
-    "./models/room-state": matrixPath + "matrix_sdk/models/room-state.js",
-    "./room-summary": matrixPath + "matrix_sdk/models/room-summary.js",
-    "./models/search-result": matrixPath + "matrix_sdk/models/search-result.js",
-    "./models/user": matrixPath + "matrix_sdk/models/user.js",
+    "../../models/event": matrixPath + "matrix_sdk/models/event.js",
+    "../lib/models/event": matrixPath + "matrix_sdk/models/event.js",
+    "../../lib/models/event": matrixPath + "matrix_sdk/models/event.js",
     "../models/user": matrixPath + "matrix_sdk/models/user.js",
 
-    // store
-    "./store/memory": matrixPath + "matrix_sdk/store/memory.js",
-    "./store/session/webstorage": matrixPath + "matrix_sdk/store/session/webstorage.js",
-    "./store/stub": matrixPath + "matrix_sdk/store/stub.js",
-    "./store/webstorage": matrixPath + "matrix_sdk/store/webstorage.js",
-
-    // webrtc
-    "./webrtc/call": matrixPath + "matrix_sdk/webrtc/call.js",
-
     // Simple (one-file) dependencies.
     "another-json": matrixPath + "another-json.js",
     events: matrixPath + "events.js",
     punycode: matrixPath + "punycode.js",
     url: matrixPath + "url.js",
 
     // Browser Request.
     "browser-request": matrixPath + "browser_request/index.js",
 
     // q
     q: matrixPath + "q/q.js",
-    "./q": matrixPath + "q/q.js",
 
     // querystring
     decode: matrixPath + "querystring/decode.js",
-    "./decode": matrixPath + "querystring/decode.js",
     encode: matrixPath + "querystring/encode.js",
-    "./encode": matrixPath + "querystring/encode.js",
     querystring: matrixPath + "querystring/index.js",
   },
   globals: {
     global: {
       setInterval,
       clearInterval,
       setTimeout,
       clearTimeout,